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