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