Skip to main content

datasynth_core/models/
journal_entry.rs

1//! Journal Entry data structures for General Ledger postings.
2//!
3//! This module defines the core journal entry structures that form the basis
4//! of double-entry bookkeeping. Each journal entry consists of a header and
5//! one or more line items that must balance (total debits = total credits).
6
7use chrono::{DateTime, NaiveDate, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use smallvec::SmallVec;
11use uuid::Uuid;
12
13use super::anomaly::FraudType;
14use super::approval::ApprovalWorkflow;
15
16/// Source of a journal entry transaction.
17///
18/// Distinguishes between manual human entries and automated system postings,
19/// which is critical for audit trail analysis and fraud detection.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum TransactionSource {
23    /// Manual entry by human user during working hours
24    #[default]
25    Manual,
26    /// Automated system posting (interfaces, batch jobs, EDI)
27    Automated,
28    /// Recurring scheduled posting (depreciation, amortization)
29    Recurring,
30    /// Reversal of a previous entry
31    Reversal,
32    /// Period-end adjustment entry
33    Adjustment,
34    /// Statistical posting (memo only, no financial impact)
35    Statistical,
36}
37
38impl std::fmt::Display for TransactionSource {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            Self::Manual => write!(f, "manual"),
42            Self::Automated => write!(f, "automated"),
43            Self::Recurring => write!(f, "recurring"),
44            Self::Reversal => write!(f, "reversal"),
45            Self::Adjustment => write!(f, "adjustment"),
46            Self::Statistical => write!(f, "statistical"),
47        }
48    }
49}
50
51// Note: FraudType is defined in anomaly.rs and re-exported from mod.rs
52// Use `crate::models::FraudType` for fraud type classification.
53
54/// Business process that originated the transaction.
55///
56/// Aligns with standard enterprise process frameworks for process mining
57/// and analytics integration.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
59#[serde(rename_all = "UPPERCASE")]
60pub enum BusinessProcess {
61    /// Order-to-Cash: sales, billing, accounts receivable
62    O2C,
63    /// Procure-to-Pay: purchasing, accounts payable
64    P2P,
65    /// Record-to-Report: GL, consolidation, reporting
66    #[default]
67    R2R,
68    /// Hire-to-Retire: payroll, HR accounting
69    H2R,
70    /// Acquire-to-Retire: fixed assets, depreciation
71    A2R,
72    /// Source-to-Contract: sourcing, supplier qualification, RFx
73    S2C,
74    /// Manufacturing: production orders, quality, cycle counts
75    #[serde(rename = "MFG")]
76    Mfg,
77    /// Banking operations: KYC/AML, accounts, transactions
78    #[serde(rename = "BANK")]
79    Bank,
80    /// Audit engagement lifecycle
81    #[serde(rename = "AUDIT")]
82    Audit,
83    /// Treasury operations
84    Treasury,
85    /// Tax accounting
86    Tax,
87    /// Intercompany transactions
88    Intercompany,
89    /// Project accounting lifecycle
90    #[serde(rename = "PROJECT")]
91    ProjectAccounting,
92    /// ESG / Sustainability reporting
93    #[serde(rename = "ESG")]
94    Esg,
95}
96
97/// Document type classification for journal entries.
98///
99/// Standard SAP-compatible document type codes.
100#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
101pub struct DocumentType {
102    /// Two-character document type code (e.g., "SA", "KR", "DR")
103    pub code: String,
104    /// Human-readable description
105    pub description: String,
106    /// Associated business process
107    pub business_process: BusinessProcess,
108    /// Is this a reversal document type
109    pub is_reversal: bool,
110}
111
112impl DocumentType {
113    /// Standard GL account document
114    pub fn gl_account() -> Self {
115        Self {
116            code: "SA".to_string(),
117            description: "G/L Account Document".to_string(),
118            business_process: BusinessProcess::R2R,
119            is_reversal: false,
120        }
121    }
122
123    /// Vendor invoice
124    pub fn vendor_invoice() -> Self {
125        Self {
126            code: "KR".to_string(),
127            description: "Vendor Invoice".to_string(),
128            business_process: BusinessProcess::P2P,
129            is_reversal: false,
130        }
131    }
132
133    /// Customer invoice
134    pub fn customer_invoice() -> Self {
135        Self {
136            code: "DR".to_string(),
137            description: "Customer Invoice".to_string(),
138            business_process: BusinessProcess::O2C,
139            is_reversal: false,
140        }
141    }
142
143    /// Vendor payment
144    pub fn vendor_payment() -> Self {
145        Self {
146            code: "KZ".to_string(),
147            description: "Vendor Payment".to_string(),
148            business_process: BusinessProcess::P2P,
149            is_reversal: false,
150        }
151    }
152
153    /// Customer payment
154    pub fn customer_payment() -> Self {
155        Self {
156            code: "DZ".to_string(),
157            description: "Customer Payment".to_string(),
158            business_process: BusinessProcess::O2C,
159            is_reversal: false,
160        }
161    }
162
163    /// Asset posting
164    pub fn asset_posting() -> Self {
165        Self {
166            code: "AA".to_string(),
167            description: "Asset Posting".to_string(),
168            business_process: BusinessProcess::A2R,
169            is_reversal: false,
170        }
171    }
172
173    /// Payroll posting
174    pub fn payroll() -> Self {
175        Self {
176            code: "PR".to_string(),
177            description: "Payroll Document".to_string(),
178            business_process: BusinessProcess::H2R,
179            is_reversal: false,
180        }
181    }
182}
183
184/// Header information for a journal entry document.
185///
186/// Contains all metadata about the posting including timing, user, and
187/// organizational assignment.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct JournalEntryHeader {
190    /// Unique identifier for this journal entry (UUID v7 for time-ordering)
191    pub document_id: Uuid,
192
193    /// Company code this entry belongs to
194    pub company_code: String,
195
196    /// Fiscal year (4-digit)
197    pub fiscal_year: u16,
198
199    /// Fiscal period (1-12, or 13-16 for special periods)
200    pub fiscal_period: u8,
201
202    /// Posting date (when the entry affects the books)
203    pub posting_date: NaiveDate,
204
205    /// Document date (date on source document)
206    pub document_date: NaiveDate,
207
208    /// Entry timestamp (when created in system)
209    pub created_at: DateTime<Utc>,
210
211    /// Document type code
212    pub document_type: String,
213
214    /// Transaction currency (ISO 4217)
215    pub currency: String,
216
217    /// Exchange rate to local currency (1.0 if same currency)
218    #[serde(with = "rust_decimal::serde::str")]
219    pub exchange_rate: Decimal,
220
221    /// Reference document number (external reference)
222    pub reference: Option<String>,
223
224    /// Header text/description
225    pub header_text: Option<String>,
226
227    /// User who created the entry
228    pub created_by: String,
229
230    /// User persona classification for behavioral analysis
231    pub user_persona: String,
232
233    /// Transaction source (manual vs automated)
234    pub source: TransactionSource,
235
236    /// Business process reference
237    pub business_process: Option<BusinessProcess>,
238
239    /// Ledger (0L = Leading Ledger)
240    pub ledger: String,
241
242    /// Is this entry part of a fraud scenario
243    pub is_fraud: bool,
244
245    /// Fraud type if applicable
246    pub fraud_type: Option<FraudType>,
247
248    // --- Anomaly Tracking Fields ---
249    /// Whether this entry has an injected anomaly
250    #[serde(default)]
251    pub is_anomaly: bool,
252
253    /// Unique anomaly identifier for label linkage
254    #[serde(default)]
255    pub anomaly_id: Option<String>,
256
257    /// Type of anomaly if applicable (serialized enum name)
258    #[serde(default)]
259    pub anomaly_type: Option<String>,
260
261    /// Simulation batch ID for traceability
262    pub batch_id: Option<Uuid>,
263
264    // --- Internal Controls / SOX Compliance Fields ---
265    /// Internal control IDs that apply to this transaction
266    #[serde(default)]
267    pub control_ids: Vec<String>,
268
269    /// Whether this is a SOX-relevant transaction
270    #[serde(default)]
271    pub sox_relevant: bool,
272
273    /// Control status for this transaction
274    #[serde(default)]
275    pub control_status: super::internal_control::ControlStatus,
276
277    /// Whether a Segregation of Duties violation occurred
278    #[serde(default)]
279    pub sod_violation: bool,
280
281    /// Type of SoD conflict if violation occurred
282    #[serde(default)]
283    pub sod_conflict_type: Option<super::sod::SodConflictType>,
284
285    // --- Approval Workflow ---
286    /// Approval workflow for high-value transactions
287    #[serde(default)]
288    pub approval_workflow: Option<ApprovalWorkflow>,
289
290    // --- OCPM (Object-Centric Process Mining) Traceability ---
291    /// OCPM event IDs that triggered this journal entry
292    #[serde(default)]
293    pub ocpm_event_ids: Vec<Uuid>,
294
295    /// OCPM object IDs involved in this journal entry
296    #[serde(default)]
297    pub ocpm_object_ids: Vec<Uuid>,
298
299    /// OCPM case ID for process instance tracking
300    #[serde(default)]
301    pub ocpm_case_id: Option<Uuid>,
302}
303
304impl JournalEntryHeader {
305    /// Create a new journal entry header with default values.
306    pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
307        Self {
308            document_id: Uuid::now_v7(),
309            company_code,
310            fiscal_year: posting_date.year() as u16,
311            fiscal_period: posting_date.month() as u8,
312            posting_date,
313            document_date: posting_date,
314            created_at: Utc::now(),
315            document_type: "SA".to_string(),
316            currency: "USD".to_string(),
317            exchange_rate: Decimal::ONE,
318            reference: None,
319            header_text: None,
320            created_by: "SYSTEM".to_string(),
321            user_persona: "automated_system".to_string(),
322            source: TransactionSource::Automated,
323            business_process: Some(BusinessProcess::R2R),
324            ledger: "0L".to_string(),
325            is_fraud: false,
326            fraud_type: None,
327            // Anomaly tracking
328            is_anomaly: false,
329            anomaly_id: None,
330            anomaly_type: None,
331            batch_id: None,
332            // Internal Controls / SOX fields
333            control_ids: Vec::new(),
334            sox_relevant: false,
335            control_status: super::internal_control::ControlStatus::default(),
336            sod_violation: false,
337            sod_conflict_type: None,
338            // Approval workflow
339            approval_workflow: None,
340            // OCPM traceability
341            ocpm_event_ids: Vec::new(),
342            ocpm_object_ids: Vec::new(),
343            ocpm_case_id: None,
344        }
345    }
346
347    /// Create a new journal entry header with a deterministic document ID.
348    ///
349    /// Used for reproducible generation where the document ID is derived
350    /// from a seed and counter.
351    pub fn with_deterministic_id(
352        company_code: String,
353        posting_date: NaiveDate,
354        document_id: Uuid,
355    ) -> Self {
356        Self {
357            document_id,
358            company_code,
359            fiscal_year: posting_date.year() as u16,
360            fiscal_period: posting_date.month() as u8,
361            posting_date,
362            document_date: posting_date,
363            created_at: Utc::now(),
364            document_type: "SA".to_string(),
365            currency: "USD".to_string(),
366            exchange_rate: Decimal::ONE,
367            reference: None,
368            header_text: None,
369            created_by: "SYSTEM".to_string(),
370            user_persona: "automated_system".to_string(),
371            source: TransactionSource::Automated,
372            business_process: Some(BusinessProcess::R2R),
373            ledger: "0L".to_string(),
374            is_fraud: false,
375            fraud_type: None,
376            // Anomaly tracking
377            is_anomaly: false,
378            anomaly_id: None,
379            anomaly_type: None,
380            batch_id: None,
381            // Internal Controls / SOX fields
382            control_ids: Vec::new(),
383            sox_relevant: false,
384            control_status: super::internal_control::ControlStatus::default(),
385            sod_violation: false,
386            sod_conflict_type: None,
387            // Approval workflow
388            approval_workflow: None,
389            // OCPM traceability
390            ocpm_event_ids: Vec::new(),
391            ocpm_object_ids: Vec::new(),
392            ocpm_case_id: None,
393        }
394    }
395}
396
397use chrono::Datelike;
398
399/// Individual line item within a journal entry.
400///
401/// Each line represents a debit or credit posting to a specific GL account.
402/// Line items must be balanced within a journal entry (sum of debits = sum of credits).
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct JournalEntryLine {
405    /// Parent document ID (matches header)
406    pub document_id: Uuid,
407
408    /// Line item number within document (1-based)
409    pub line_number: u32,
410
411    /// GL account number
412    pub gl_account: String,
413
414    /// Account code (alias for gl_account for compatibility)
415    #[serde(default)]
416    pub account_code: String,
417
418    /// Account description (for display)
419    #[serde(default)]
420    pub account_description: Option<String>,
421
422    /// Debit amount in transaction currency (positive or zero)
423    #[serde(with = "rust_decimal::serde::str")]
424    pub debit_amount: Decimal,
425
426    /// Credit amount in transaction currency (positive or zero)
427    #[serde(with = "rust_decimal::serde::str")]
428    pub credit_amount: Decimal,
429
430    /// Amount in local/company currency
431    #[serde(with = "rust_decimal::serde::str")]
432    pub local_amount: Decimal,
433
434    /// Amount in group currency (for consolidation)
435    #[serde(default, with = "rust_decimal::serde::str_option")]
436    pub group_amount: Option<Decimal>,
437
438    /// Cost center assignment
439    pub cost_center: Option<String>,
440
441    /// Profit center assignment
442    pub profit_center: Option<String>,
443
444    /// Segment for segment reporting
445    pub segment: Option<String>,
446
447    /// Functional area
448    pub functional_area: Option<String>,
449
450    /// Line item text/description
451    pub line_text: Option<String>,
452
453    /// Text field (alias for line_text for compatibility)
454    #[serde(default)]
455    pub text: Option<String>,
456
457    /// Reference field
458    #[serde(default)]
459    pub reference: Option<String>,
460
461    /// Value date (for interest calculations)
462    #[serde(default)]
463    pub value_date: Option<NaiveDate>,
464
465    /// Tax code
466    pub tax_code: Option<String>,
467
468    /// Tax amount
469    #[serde(default, with = "rust_decimal::serde::str_option")]
470    pub tax_amount: Option<Decimal>,
471
472    /// Assignment field (for account assignment)
473    pub assignment: Option<String>,
474
475    /// Reference to offsetting account (for network generation)
476    pub offsetting_account: Option<String>,
477
478    /// Is this posting to a suspense/clearing account
479    pub is_suspense: bool,
480
481    /// Trading partner company code (for intercompany)
482    pub trading_partner: Option<String>,
483
484    /// Quantity (for quantity-based postings)
485    #[serde(default, with = "rust_decimal::serde::str_option")]
486    pub quantity: Option<Decimal>,
487
488    /// Unit of measure
489    pub unit_of_measure: Option<String>,
490
491    /// Unit (alias for unit_of_measure for compatibility)
492    #[serde(default)]
493    pub unit: Option<String>,
494
495    /// Project code
496    #[serde(default)]
497    pub project_code: Option<String>,
498}
499
500impl JournalEntryLine {
501    /// Create a new debit line item.
502    #[inline]
503    pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
504        Self {
505            document_id,
506            line_number,
507            gl_account: gl_account.clone(),
508            account_code: gl_account,
509            account_description: None,
510            debit_amount: amount,
511            credit_amount: Decimal::ZERO,
512            local_amount: amount,
513            group_amount: None,
514            cost_center: None,
515            profit_center: None,
516            segment: None,
517            functional_area: None,
518            line_text: None,
519            text: None,
520            reference: None,
521            value_date: None,
522            tax_code: None,
523            tax_amount: None,
524            assignment: None,
525            offsetting_account: None,
526            is_suspense: false,
527            trading_partner: None,
528            quantity: None,
529            unit_of_measure: None,
530            unit: None,
531            project_code: None,
532        }
533    }
534
535    /// Create a new credit line item.
536    #[inline]
537    pub fn credit(
538        document_id: Uuid,
539        line_number: u32,
540        gl_account: String,
541        amount: Decimal,
542    ) -> Self {
543        Self {
544            document_id,
545            line_number,
546            gl_account: gl_account.clone(),
547            account_code: gl_account,
548            account_description: None,
549            debit_amount: Decimal::ZERO,
550            credit_amount: amount,
551            local_amount: -amount,
552            group_amount: None,
553            cost_center: None,
554            profit_center: None,
555            segment: None,
556            functional_area: None,
557            line_text: None,
558            text: None,
559            reference: None,
560            value_date: None,
561            tax_code: None,
562            tax_amount: None,
563            assignment: None,
564            offsetting_account: None,
565            is_suspense: false,
566            trading_partner: None,
567            quantity: None,
568            unit_of_measure: None,
569            unit: None,
570            project_code: None,
571        }
572    }
573
574    /// Check if this is a debit posting.
575    #[inline]
576    pub fn is_debit(&self) -> bool {
577        self.debit_amount > Decimal::ZERO
578    }
579
580    /// Check if this is a credit posting.
581    #[inline]
582    pub fn is_credit(&self) -> bool {
583        self.credit_amount > Decimal::ZERO
584    }
585
586    /// Get the signed amount (positive for debit, negative for credit).
587    #[inline]
588    pub fn signed_amount(&self) -> Decimal {
589        self.debit_amount - self.credit_amount
590    }
591
592    // Convenience accessors for compatibility
593
594    /// Get the account code (alias for gl_account).
595    #[allow(clippy::misnamed_getters)]
596    pub fn account_code(&self) -> &str {
597        &self.gl_account
598    }
599
600    /// Get the account description (currently returns empty string as not stored).
601    pub fn account_description(&self) -> &str {
602        // Account descriptions are typically looked up from CoA, not stored per line
603        ""
604    }
605}
606
607impl Default for JournalEntryLine {
608    fn default() -> Self {
609        Self {
610            document_id: Uuid::nil(),
611            line_number: 0,
612            gl_account: String::new(),
613            account_code: String::new(),
614            account_description: None,
615            debit_amount: Decimal::ZERO,
616            credit_amount: Decimal::ZERO,
617            local_amount: Decimal::ZERO,
618            group_amount: None,
619            cost_center: None,
620            profit_center: None,
621            segment: None,
622            functional_area: None,
623            line_text: None,
624            text: None,
625            reference: None,
626            value_date: None,
627            tax_code: None,
628            tax_amount: None,
629            assignment: None,
630            offsetting_account: None,
631            is_suspense: false,
632            trading_partner: None,
633            quantity: None,
634            unit_of_measure: None,
635            unit: None,
636            project_code: None,
637        }
638    }
639}
640
641/// Complete journal entry with header and line items.
642///
643/// Represents a balanced double-entry bookkeeping transaction where
644/// total debits must equal total credits.
645///
646/// Uses `SmallVec<[JournalEntryLine; 4]>` for line items: entries with
647/// 4 or fewer lines (the common case) are stored inline on the stack,
648/// avoiding heap allocation. Entries with more lines spill to the heap
649/// transparently.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub struct JournalEntry {
652    /// Header with document metadata
653    pub header: JournalEntryHeader,
654    /// Line items (debit and credit postings).
655    /// Inline for ≤4 lines (common case), heap-allocated for >4.
656    pub lines: SmallVec<[JournalEntryLine; 4]>,
657}
658
659impl JournalEntry {
660    /// Create a new journal entry with header and empty lines.
661    pub fn new(header: JournalEntryHeader) -> Self {
662        Self {
663            header,
664            lines: SmallVec::new(),
665        }
666    }
667
668    /// Create a new journal entry with basic parameters (convenience constructor).
669    ///
670    /// This is a simplified constructor for backwards compatibility that creates
671    /// a journal entry with the specified document number, company code, posting date,
672    /// and description.
673    pub fn new_simple(
674        _document_number: String,
675        company_code: String,
676        posting_date: NaiveDate,
677        description: String,
678    ) -> Self {
679        let mut header = JournalEntryHeader::new(company_code, posting_date);
680        header.header_text = Some(description);
681        Self {
682            header,
683            lines: SmallVec::new(),
684        }
685    }
686
687    /// Add a line item to the journal entry.
688    #[inline]
689    pub fn add_line(&mut self, line: JournalEntryLine) {
690        self.lines.push(line);
691    }
692
693    /// Get the total debit amount.
694    pub fn total_debit(&self) -> Decimal {
695        self.lines.iter().map(|l| l.debit_amount).sum()
696    }
697
698    /// Get the total credit amount.
699    pub fn total_credit(&self) -> Decimal {
700        self.lines.iter().map(|l| l.credit_amount).sum()
701    }
702
703    /// Check if the journal entry is balanced (debits = credits).
704    pub fn is_balanced(&self) -> bool {
705        self.total_debit() == self.total_credit()
706    }
707
708    /// Get the out-of-balance amount (should be zero for valid entries).
709    pub fn balance_difference(&self) -> Decimal {
710        self.total_debit() - self.total_credit()
711    }
712
713    /// Get the number of line items.
714    pub fn line_count(&self) -> usize {
715        self.lines.len()
716    }
717
718    /// Check if the line count is even.
719    pub fn has_even_line_count(&self) -> bool {
720        self.lines.len().is_multiple_of(2)
721    }
722
723    /// Get the count of debit and credit lines.
724    pub fn debit_credit_counts(&self) -> (usize, usize) {
725        let debits = self.lines.iter().filter(|l| l.is_debit()).count();
726        let credits = self.lines.iter().filter(|l| l.is_credit()).count();
727        (debits, credits)
728    }
729
730    /// Check if debit and credit line counts are equal.
731    pub fn has_equal_debit_credit_counts(&self) -> bool {
732        let (d, c) = self.debit_credit_counts();
733        d == c
734    }
735
736    /// Get unique GL accounts used in this entry.
737    pub fn unique_accounts(&self) -> Vec<&str> {
738        let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
739        accounts.sort();
740        accounts.dedup();
741        accounts
742    }
743
744    /// Check if any line posts to a suspense account.
745    pub fn has_suspense_posting(&self) -> bool {
746        self.lines.iter().any(|l| l.is_suspense)
747    }
748
749    // Convenience accessors for header fields
750
751    /// Get the company code.
752    pub fn company_code(&self) -> &str {
753        &self.header.company_code
754    }
755
756    /// Get the document number (document_id as string).
757    pub fn document_number(&self) -> String {
758        self.header.document_id.to_string()
759    }
760
761    /// Get the posting date.
762    pub fn posting_date(&self) -> NaiveDate {
763        self.header.posting_date
764    }
765
766    /// Get the document date.
767    pub fn document_date(&self) -> NaiveDate {
768        self.header.document_date
769    }
770
771    /// Get the fiscal year.
772    pub fn fiscal_year(&self) -> u16 {
773        self.header.fiscal_year
774    }
775
776    /// Get the fiscal period.
777    pub fn fiscal_period(&self) -> u8 {
778        self.header.fiscal_period
779    }
780
781    /// Get the currency.
782    pub fn currency(&self) -> &str {
783        &self.header.currency
784    }
785
786    /// Check if this entry is marked as fraud.
787    pub fn is_fraud(&self) -> bool {
788        self.header.is_fraud
789    }
790
791    /// Check if this entry has a SOD violation.
792    pub fn has_sod_violation(&self) -> bool {
793        self.header.sod_violation
794    }
795
796    /// Get the description (header text).
797    pub fn description(&self) -> Option<&str> {
798        self.header.header_text.as_deref()
799    }
800
801    /// Set the description (header text).
802    pub fn set_description(&mut self, description: String) {
803        self.header.header_text = Some(description);
804    }
805}
806
807#[cfg(test)]
808#[allow(clippy::unwrap_used)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn test_balanced_entry() {
814        let header = JournalEntryHeader::new(
815            "1000".to_string(),
816            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
817        );
818        let mut entry = JournalEntry::new(header);
819
820        entry.add_line(JournalEntryLine::debit(
821            entry.header.document_id,
822            1,
823            "100000".to_string(),
824            Decimal::from(1000),
825        ));
826        entry.add_line(JournalEntryLine::credit(
827            entry.header.document_id,
828            2,
829            "200000".to_string(),
830            Decimal::from(1000),
831        ));
832
833        assert!(entry.is_balanced());
834        assert_eq!(entry.line_count(), 2);
835        assert!(entry.has_even_line_count());
836        assert!(entry.has_equal_debit_credit_counts());
837    }
838
839    #[test]
840    fn test_unbalanced_entry() {
841        let header = JournalEntryHeader::new(
842            "1000".to_string(),
843            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
844        );
845        let mut entry = JournalEntry::new(header);
846
847        entry.add_line(JournalEntryLine::debit(
848            entry.header.document_id,
849            1,
850            "100000".to_string(),
851            Decimal::from(1000),
852        ));
853        entry.add_line(JournalEntryLine::credit(
854            entry.header.document_id,
855            2,
856            "200000".to_string(),
857            Decimal::from(500),
858        ));
859
860        assert!(!entry.is_balanced());
861        assert_eq!(entry.balance_difference(), Decimal::from(500));
862    }
863}