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