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