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