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    /// Whether this is a consolidation elimination entry
331    #[serde(default)]
332    pub is_elimination: bool,
333
334    // --- Approval Workflow ---
335    /// Approval workflow for high-value transactions
336    #[serde(default)]
337    pub approval_workflow: Option<ApprovalWorkflow>,
338
339    // --- Source Document + Approval Tracking ---
340    /// Typed reference to the source document (derived from `reference` field prefix)
341    #[serde(default)]
342    pub source_document: Option<DocumentRef>,
343    /// Employee ID of the approver (for SOD analysis)
344    #[serde(default)]
345    pub approved_by: Option<String>,
346    /// Date of approval
347    #[serde(default)]
348    pub approval_date: Option<NaiveDate>,
349
350    // --- OCPM (Object-Centric Process Mining) Traceability ---
351    /// OCPM event IDs that triggered this journal entry
352    #[serde(default)]
353    pub ocpm_event_ids: Vec<Uuid>,
354
355    /// OCPM object IDs involved in this journal entry
356    #[serde(default)]
357    pub ocpm_object_ids: Vec<Uuid>,
358
359    /// OCPM case ID for process instance tracking
360    #[serde(default)]
361    pub ocpm_case_id: Option<Uuid>,
362}
363
364impl JournalEntryHeader {
365    /// Create a new journal entry header with default values.
366    pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
367        Self {
368            document_id: Uuid::now_v7(),
369            company_code,
370            fiscal_year: posting_date.year() as u16,
371            fiscal_period: posting_date.month() as u8,
372            posting_date,
373            document_date: posting_date,
374            created_at: Utc::now(),
375            document_type: "SA".to_string(),
376            currency: "USD".to_string(),
377            exchange_rate: Decimal::ONE,
378            reference: None,
379            header_text: None,
380            created_by: "SYSTEM".to_string(),
381            user_persona: "automated_system".to_string(),
382            source: TransactionSource::Automated,
383            business_process: Some(BusinessProcess::R2R),
384            ledger: "0L".to_string(),
385            is_fraud: false,
386            fraud_type: None,
387            // Anomaly tracking
388            is_anomaly: false,
389            anomaly_id: None,
390            anomaly_type: None,
391            batch_id: None,
392            // Internal Controls / SOX fields
393            control_ids: Vec::new(),
394            sox_relevant: false,
395            control_status: super::internal_control::ControlStatus::default(),
396            sod_violation: false,
397            sod_conflict_type: None,
398            // Consolidation elimination flag
399            is_elimination: false,
400            // Approval workflow
401            approval_workflow: None,
402            // Source document + approval tracking
403            source_document: None,
404            approved_by: None,
405            approval_date: None,
406            // OCPM traceability
407            ocpm_event_ids: Vec::new(),
408            ocpm_object_ids: Vec::new(),
409            ocpm_case_id: None,
410        }
411    }
412
413    /// Create a new journal entry header with a deterministic document ID.
414    ///
415    /// Used for reproducible generation where the document ID is derived
416    /// from a seed and counter.
417    pub fn with_deterministic_id(
418        company_code: String,
419        posting_date: NaiveDate,
420        document_id: Uuid,
421    ) -> Self {
422        Self {
423            document_id,
424            company_code,
425            fiscal_year: posting_date.year() as u16,
426            fiscal_period: posting_date.month() as u8,
427            posting_date,
428            document_date: posting_date,
429            created_at: Utc::now(),
430            document_type: "SA".to_string(),
431            currency: "USD".to_string(),
432            exchange_rate: Decimal::ONE,
433            reference: None,
434            header_text: None,
435            created_by: "SYSTEM".to_string(),
436            user_persona: "automated_system".to_string(),
437            source: TransactionSource::Automated,
438            business_process: Some(BusinessProcess::R2R),
439            ledger: "0L".to_string(),
440            is_fraud: false,
441            fraud_type: None,
442            // Anomaly tracking
443            is_anomaly: false,
444            anomaly_id: None,
445            anomaly_type: None,
446            batch_id: None,
447            // Internal Controls / SOX fields
448            control_ids: Vec::new(),
449            sox_relevant: false,
450            control_status: super::internal_control::ControlStatus::default(),
451            sod_violation: false,
452            sod_conflict_type: None,
453            // Consolidation elimination flag
454            is_elimination: false,
455            // Approval workflow
456            approval_workflow: None,
457            // Source document + approval tracking
458            source_document: None,
459            approved_by: None,
460            approval_date: None,
461            // OCPM traceability
462            ocpm_event_ids: Vec::new(),
463            ocpm_object_ids: Vec::new(),
464            ocpm_case_id: None,
465        }
466    }
467}
468
469use chrono::Datelike;
470
471/// Individual line item within a journal entry.
472///
473/// Each line represents a debit or credit posting to a specific GL account.
474/// Line items must be balanced within a journal entry (sum of debits = sum of credits).
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct JournalEntryLine {
477    /// Parent document ID (matches header)
478    pub document_id: Uuid,
479
480    /// Line item number within document (1-based)
481    pub line_number: u32,
482
483    /// GL account number
484    pub gl_account: String,
485
486    /// Account code (alias for gl_account for compatibility)
487    #[serde(default)]
488    pub account_code: String,
489
490    /// Account description (for display)
491    #[serde(default)]
492    pub account_description: Option<String>,
493
494    /// Debit amount in transaction currency (positive or zero)
495    #[serde(with = "rust_decimal::serde::str")]
496    pub debit_amount: Decimal,
497
498    /// Credit amount in transaction currency (positive or zero)
499    #[serde(with = "rust_decimal::serde::str")]
500    pub credit_amount: Decimal,
501
502    /// Amount in local/company currency
503    #[serde(with = "rust_decimal::serde::str")]
504    pub local_amount: Decimal,
505
506    /// Amount in group currency (for consolidation)
507    #[serde(default, with = "rust_decimal::serde::str_option")]
508    pub group_amount: Option<Decimal>,
509
510    /// Cost center assignment
511    pub cost_center: Option<String>,
512
513    /// Profit center assignment
514    pub profit_center: Option<String>,
515
516    /// Segment for segment reporting
517    pub segment: Option<String>,
518
519    /// Functional area
520    pub functional_area: Option<String>,
521
522    /// Line item text/description
523    pub line_text: Option<String>,
524
525    /// Text field (alias for line_text for compatibility)
526    #[serde(default)]
527    pub text: Option<String>,
528
529    /// Reference field
530    #[serde(default)]
531    pub reference: Option<String>,
532
533    /// Value date (for interest calculations)
534    #[serde(default)]
535    pub value_date: Option<NaiveDate>,
536
537    /// Tax code
538    pub tax_code: Option<String>,
539
540    /// Tax amount
541    #[serde(default, with = "rust_decimal::serde::str_option")]
542    pub tax_amount: Option<Decimal>,
543
544    /// Assignment field (for account assignment)
545    pub assignment: Option<String>,
546
547    /// Reference to offsetting account (for network generation)
548    pub offsetting_account: Option<String>,
549
550    /// Is this posting to a suspense/clearing account
551    pub is_suspense: bool,
552
553    /// Trading partner company code (for intercompany)
554    pub trading_partner: Option<String>,
555
556    /// Quantity (for quantity-based postings)
557    #[serde(default, with = "rust_decimal::serde::str_option")]
558    pub quantity: Option<Decimal>,
559
560    /// Unit of measure
561    pub unit_of_measure: Option<String>,
562
563    /// Unit (alias for unit_of_measure for compatibility)
564    #[serde(default)]
565    pub unit: Option<String>,
566
567    /// Project code
568    #[serde(default)]
569    pub project_code: Option<String>,
570
571    /// Auxiliary account number (FEC column 7: Numéro de compte auxiliaire)
572    /// Populated for AP/AR lines under French GAAP with the business partner ID.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub auxiliary_account_number: Option<String>,
575
576    /// Auxiliary account label (FEC column 8: Libellé de compte auxiliaire)
577    /// Populated for AP/AR lines under French GAAP with the business partner name.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub auxiliary_account_label: Option<String>,
580
581    /// Lettrage code (FEC column 14: Lettrage)
582    /// Links offset postings in a completed document chain (e.g. invoice ↔ payment).
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub lettrage: Option<String>,
585
586    /// Lettrage date (FEC column 15: Date de lettrage)
587    /// Date when the matching was performed (typically the payment posting date).
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub lettrage_date: Option<NaiveDate>,
590}
591
592impl JournalEntryLine {
593    /// Create a new debit line item.
594    #[inline]
595    pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
596        Self {
597            document_id,
598            line_number,
599            gl_account: gl_account.clone(),
600            account_code: gl_account,
601            account_description: None,
602            debit_amount: amount,
603            credit_amount: Decimal::ZERO,
604            local_amount: amount,
605            group_amount: None,
606            cost_center: None,
607            profit_center: None,
608            segment: None,
609            functional_area: None,
610            line_text: None,
611            text: None,
612            reference: None,
613            value_date: None,
614            tax_code: None,
615            tax_amount: None,
616            assignment: None,
617            offsetting_account: None,
618            is_suspense: false,
619            trading_partner: None,
620            quantity: None,
621            unit_of_measure: None,
622            unit: None,
623            project_code: None,
624            auxiliary_account_number: None,
625            auxiliary_account_label: None,
626            lettrage: None,
627            lettrage_date: None,
628        }
629    }
630
631    /// Create a new credit line item.
632    #[inline]
633    pub fn credit(
634        document_id: Uuid,
635        line_number: u32,
636        gl_account: String,
637        amount: Decimal,
638    ) -> Self {
639        Self {
640            document_id,
641            line_number,
642            gl_account: gl_account.clone(),
643            account_code: gl_account,
644            account_description: None,
645            debit_amount: Decimal::ZERO,
646            credit_amount: amount,
647            local_amount: -amount,
648            group_amount: None,
649            cost_center: None,
650            profit_center: None,
651            segment: None,
652            functional_area: None,
653            line_text: None,
654            text: None,
655            reference: None,
656            value_date: None,
657            tax_code: None,
658            tax_amount: None,
659            assignment: None,
660            offsetting_account: None,
661            is_suspense: false,
662            trading_partner: None,
663            quantity: None,
664            unit_of_measure: None,
665            unit: None,
666            project_code: None,
667            auxiliary_account_number: None,
668            auxiliary_account_label: None,
669            lettrage: None,
670            lettrage_date: None,
671        }
672    }
673
674    /// Check if this is a debit posting.
675    #[inline]
676    pub fn is_debit(&self) -> bool {
677        self.debit_amount > Decimal::ZERO
678    }
679
680    /// Check if this is a credit posting.
681    #[inline]
682    pub fn is_credit(&self) -> bool {
683        self.credit_amount > Decimal::ZERO
684    }
685
686    /// Get the signed amount (positive for debit, negative for credit).
687    #[inline]
688    pub fn signed_amount(&self) -> Decimal {
689        self.debit_amount - self.credit_amount
690    }
691
692    // Convenience accessors for compatibility
693
694    /// Get the account code (alias for gl_account).
695    #[allow(clippy::misnamed_getters)]
696    pub fn account_code(&self) -> &str {
697        &self.gl_account
698    }
699
700    /// Get the account description (currently returns empty string as not stored).
701    pub fn account_description(&self) -> &str {
702        // Account descriptions are typically looked up from CoA, not stored per line
703        ""
704    }
705}
706
707impl Default for JournalEntryLine {
708    fn default() -> Self {
709        Self {
710            document_id: Uuid::nil(),
711            line_number: 0,
712            gl_account: String::new(),
713            account_code: String::new(),
714            account_description: None,
715            debit_amount: Decimal::ZERO,
716            credit_amount: Decimal::ZERO,
717            local_amount: Decimal::ZERO,
718            group_amount: None,
719            cost_center: None,
720            profit_center: None,
721            segment: None,
722            functional_area: None,
723            line_text: None,
724            text: None,
725            reference: None,
726            value_date: None,
727            tax_code: None,
728            tax_amount: None,
729            assignment: None,
730            offsetting_account: None,
731            is_suspense: false,
732            trading_partner: None,
733            quantity: None,
734            unit_of_measure: None,
735            unit: None,
736            project_code: None,
737            auxiliary_account_number: None,
738            auxiliary_account_label: None,
739            lettrage: None,
740            lettrage_date: None,
741        }
742    }
743}
744
745/// Complete journal entry with header and line items.
746///
747/// Represents a balanced double-entry bookkeeping transaction where
748/// total debits must equal total credits.
749///
750/// Uses `SmallVec<[JournalEntryLine; 4]>` for line items: entries with
751/// 4 or fewer lines (the common case) are stored inline on the stack,
752/// avoiding heap allocation. Entries with more lines spill to the heap
753/// transparently.
754#[derive(Debug, Clone, Serialize, Deserialize)]
755pub struct JournalEntry {
756    /// Header with document metadata
757    pub header: JournalEntryHeader,
758    /// Line items (debit and credit postings).
759    /// Inline for ≤4 lines (common case), heap-allocated for >4.
760    pub lines: SmallVec<[JournalEntryLine; 4]>,
761}
762
763impl JournalEntry {
764    /// Create a new journal entry with header and empty lines.
765    pub fn new(header: JournalEntryHeader) -> Self {
766        Self {
767            header,
768            lines: SmallVec::new(),
769        }
770    }
771
772    /// Create a new journal entry with basic parameters (convenience constructor).
773    ///
774    /// This is a simplified constructor for backwards compatibility that creates
775    /// a journal entry with the specified document number, company code, posting date,
776    /// and description.
777    pub fn new_simple(
778        _document_number: String,
779        company_code: String,
780        posting_date: NaiveDate,
781        description: String,
782    ) -> Self {
783        let mut header = JournalEntryHeader::new(company_code, posting_date);
784        header.header_text = Some(description);
785        Self {
786            header,
787            lines: SmallVec::new(),
788        }
789    }
790
791    /// Add a line item to the journal entry.
792    #[inline]
793    pub fn add_line(&mut self, line: JournalEntryLine) {
794        self.lines.push(line);
795    }
796
797    /// Get the total debit amount.
798    pub fn total_debit(&self) -> Decimal {
799        self.lines.iter().map(|l| l.debit_amount).sum()
800    }
801
802    /// Get the total credit amount.
803    pub fn total_credit(&self) -> Decimal {
804        self.lines.iter().map(|l| l.credit_amount).sum()
805    }
806
807    /// Check if the journal entry is balanced (debits = credits).
808    pub fn is_balanced(&self) -> bool {
809        self.total_debit() == self.total_credit()
810    }
811
812    /// Get the out-of-balance amount (should be zero for valid entries).
813    pub fn balance_difference(&self) -> Decimal {
814        self.total_debit() - self.total_credit()
815    }
816
817    /// Get the number of line items.
818    pub fn line_count(&self) -> usize {
819        self.lines.len()
820    }
821
822    /// Check if the line count is even.
823    pub fn has_even_line_count(&self) -> bool {
824        self.lines.len().is_multiple_of(2)
825    }
826
827    /// Get the count of debit and credit lines.
828    pub fn debit_credit_counts(&self) -> (usize, usize) {
829        let debits = self.lines.iter().filter(|l| l.is_debit()).count();
830        let credits = self.lines.iter().filter(|l| l.is_credit()).count();
831        (debits, credits)
832    }
833
834    /// Check if debit and credit line counts are equal.
835    pub fn has_equal_debit_credit_counts(&self) -> bool {
836        let (d, c) = self.debit_credit_counts();
837        d == c
838    }
839
840    /// Get unique GL accounts used in this entry.
841    pub fn unique_accounts(&self) -> Vec<&str> {
842        let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
843        accounts.sort();
844        accounts.dedup();
845        accounts
846    }
847
848    /// Check if any line posts to a suspense account.
849    pub fn has_suspense_posting(&self) -> bool {
850        self.lines.iter().any(|l| l.is_suspense)
851    }
852
853    // Convenience accessors for header fields
854
855    /// Get the company code.
856    pub fn company_code(&self) -> &str {
857        &self.header.company_code
858    }
859
860    /// Get the document number (document_id as string).
861    pub fn document_number(&self) -> String {
862        self.header.document_id.to_string()
863    }
864
865    /// Get the posting date.
866    pub fn posting_date(&self) -> NaiveDate {
867        self.header.posting_date
868    }
869
870    /// Get the document date.
871    pub fn document_date(&self) -> NaiveDate {
872        self.header.document_date
873    }
874
875    /// Get the fiscal year.
876    pub fn fiscal_year(&self) -> u16 {
877        self.header.fiscal_year
878    }
879
880    /// Get the fiscal period.
881    pub fn fiscal_period(&self) -> u8 {
882        self.header.fiscal_period
883    }
884
885    /// Get the currency.
886    pub fn currency(&self) -> &str {
887        &self.header.currency
888    }
889
890    /// Check if this entry is marked as fraud.
891    pub fn is_fraud(&self) -> bool {
892        self.header.is_fraud
893    }
894
895    /// Check if this entry has a SOD violation.
896    pub fn has_sod_violation(&self) -> bool {
897        self.header.sod_violation
898    }
899
900    /// Get the description (header text).
901    pub fn description(&self) -> Option<&str> {
902        self.header.header_text.as_deref()
903    }
904
905    /// Set the description (header text).
906    pub fn set_description(&mut self, description: String) {
907        self.header.header_text = Some(description);
908    }
909}
910
911#[cfg(test)]
912#[allow(clippy::unwrap_used)]
913mod tests {
914    use super::*;
915
916    #[test]
917    fn test_balanced_entry() {
918        let header = JournalEntryHeader::new(
919            "1000".to_string(),
920            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
921        );
922        let mut entry = JournalEntry::new(header);
923
924        entry.add_line(JournalEntryLine::debit(
925            entry.header.document_id,
926            1,
927            "100000".to_string(),
928            Decimal::from(1000),
929        ));
930        entry.add_line(JournalEntryLine::credit(
931            entry.header.document_id,
932            2,
933            "200000".to_string(),
934            Decimal::from(1000),
935        ));
936
937        assert!(entry.is_balanced());
938        assert_eq!(entry.line_count(), 2);
939        assert!(entry.has_even_line_count());
940        assert!(entry.has_equal_debit_credit_counts());
941    }
942
943    #[test]
944    fn test_unbalanced_entry() {
945        let header = JournalEntryHeader::new(
946            "1000".to_string(),
947            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
948        );
949        let mut entry = JournalEntry::new(header);
950
951        entry.add_line(JournalEntryLine::debit(
952            entry.header.document_id,
953            1,
954            "100000".to_string(),
955            Decimal::from(1000),
956        ));
957        entry.add_line(JournalEntryLine::credit(
958            entry.header.document_id,
959            2,
960            "200000".to_string(),
961            Decimal::from(500),
962        ));
963
964        assert!(!entry.is_balanced());
965        assert_eq!(entry.balance_difference(), Decimal::from(500));
966    }
967}