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