datasynth_core/models/
journal_entry.rs

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