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    /// Simulation batch ID for traceability
217    pub batch_id: Option<Uuid>,
218
219    // --- Internal Controls / SOX Compliance Fields ---
220    /// Internal control IDs that apply to this transaction
221    #[serde(default)]
222    pub control_ids: Vec<String>,
223
224    /// Whether this is a SOX-relevant transaction
225    #[serde(default)]
226    pub sox_relevant: bool,
227
228    /// Control status for this transaction
229    #[serde(default)]
230    pub control_status: super::internal_control::ControlStatus,
231
232    /// Whether a Segregation of Duties violation occurred
233    #[serde(default)]
234    pub sod_violation: bool,
235
236    /// Type of SoD conflict if violation occurred
237    #[serde(default)]
238    pub sod_conflict_type: Option<super::sod::SodConflictType>,
239
240    // --- Approval Workflow ---
241    /// Approval workflow for high-value transactions
242    #[serde(default)]
243    pub approval_workflow: Option<ApprovalWorkflow>,
244
245    // --- OCPM (Object-Centric Process Mining) Traceability ---
246    /// OCPM event IDs that triggered this journal entry
247    #[serde(default)]
248    pub ocpm_event_ids: Vec<Uuid>,
249
250    /// OCPM object IDs involved in this journal entry
251    #[serde(default)]
252    pub ocpm_object_ids: Vec<Uuid>,
253
254    /// OCPM case ID for process instance tracking
255    #[serde(default)]
256    pub ocpm_case_id: Option<Uuid>,
257}
258
259impl JournalEntryHeader {
260    /// Create a new journal entry header with default values.
261    pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
262        Self {
263            document_id: Uuid::now_v7(),
264            company_code,
265            fiscal_year: posting_date.year() as u16,
266            fiscal_period: posting_date.month() as u8,
267            posting_date,
268            document_date: posting_date,
269            created_at: Utc::now(),
270            document_type: "SA".to_string(),
271            currency: "USD".to_string(),
272            exchange_rate: Decimal::ONE,
273            reference: None,
274            header_text: None,
275            created_by: "SYSTEM".to_string(),
276            user_persona: "automated_system".to_string(),
277            source: TransactionSource::Automated,
278            business_process: Some(BusinessProcess::R2R),
279            ledger: "0L".to_string(),
280            is_fraud: false,
281            fraud_type: None,
282            batch_id: None,
283            // Internal Controls / SOX fields
284            control_ids: Vec::new(),
285            sox_relevant: false,
286            control_status: super::internal_control::ControlStatus::default(),
287            sod_violation: false,
288            sod_conflict_type: None,
289            // Approval workflow
290            approval_workflow: None,
291            // OCPM traceability
292            ocpm_event_ids: Vec::new(),
293            ocpm_object_ids: Vec::new(),
294            ocpm_case_id: None,
295        }
296    }
297
298    /// Create a new journal entry header with a deterministic document ID.
299    ///
300    /// Used for reproducible generation where the document ID is derived
301    /// from a seed and counter.
302    pub fn with_deterministic_id(
303        company_code: String,
304        posting_date: NaiveDate,
305        document_id: Uuid,
306    ) -> Self {
307        Self {
308            document_id,
309            company_code,
310            fiscal_year: posting_date.year() as u16,
311            fiscal_period: posting_date.month() as u8,
312            posting_date,
313            document_date: posting_date,
314            created_at: Utc::now(),
315            document_type: "SA".to_string(),
316            currency: "USD".to_string(),
317            exchange_rate: Decimal::ONE,
318            reference: None,
319            header_text: None,
320            created_by: "SYSTEM".to_string(),
321            user_persona: "automated_system".to_string(),
322            source: TransactionSource::Automated,
323            business_process: Some(BusinessProcess::R2R),
324            ledger: "0L".to_string(),
325            is_fraud: false,
326            fraud_type: None,
327            batch_id: None,
328            // Internal Controls / SOX fields
329            control_ids: Vec::new(),
330            sox_relevant: false,
331            control_status: super::internal_control::ControlStatus::default(),
332            sod_violation: false,
333            sod_conflict_type: None,
334            // Approval workflow
335            approval_workflow: None,
336            // OCPM traceability
337            ocpm_event_ids: Vec::new(),
338            ocpm_object_ids: Vec::new(),
339            ocpm_case_id: None,
340        }
341    }
342}
343
344use chrono::Datelike;
345
346/// Individual line item within a journal entry.
347///
348/// Each line represents a debit or credit posting to a specific GL account.
349/// Line items must be balanced within a journal entry (sum of debits = sum of credits).
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct JournalEntryLine {
352    /// Parent document ID (matches header)
353    pub document_id: Uuid,
354
355    /// Line item number within document (1-based)
356    pub line_number: u32,
357
358    /// GL account number
359    pub gl_account: String,
360
361    /// Account code (alias for gl_account for compatibility)
362    #[serde(default)]
363    pub account_code: String,
364
365    /// Account description (for display)
366    #[serde(default)]
367    pub account_description: Option<String>,
368
369    /// Debit amount in transaction currency (positive or zero)
370    #[serde(with = "rust_decimal::serde::str")]
371    pub debit_amount: Decimal,
372
373    /// Credit amount in transaction currency (positive or zero)
374    #[serde(with = "rust_decimal::serde::str")]
375    pub credit_amount: Decimal,
376
377    /// Amount in local/company currency
378    #[serde(with = "rust_decimal::serde::str")]
379    pub local_amount: Decimal,
380
381    /// Amount in group currency (for consolidation)
382    #[serde(default, with = "rust_decimal::serde::str_option")]
383    pub group_amount: Option<Decimal>,
384
385    /// Cost center assignment
386    pub cost_center: Option<String>,
387
388    /// Profit center assignment
389    pub profit_center: Option<String>,
390
391    /// Segment for segment reporting
392    pub segment: Option<String>,
393
394    /// Functional area
395    pub functional_area: Option<String>,
396
397    /// Line item text/description
398    pub line_text: Option<String>,
399
400    /// Text field (alias for line_text for compatibility)
401    #[serde(default)]
402    pub text: Option<String>,
403
404    /// Reference field
405    #[serde(default)]
406    pub reference: Option<String>,
407
408    /// Value date (for interest calculations)
409    #[serde(default)]
410    pub value_date: Option<NaiveDate>,
411
412    /// Tax code
413    pub tax_code: Option<String>,
414
415    /// Tax amount
416    #[serde(default, with = "rust_decimal::serde::str_option")]
417    pub tax_amount: Option<Decimal>,
418
419    /// Assignment field (for account assignment)
420    pub assignment: Option<String>,
421
422    /// Reference to offsetting account (for network generation)
423    pub offsetting_account: Option<String>,
424
425    /// Is this posting to a suspense/clearing account
426    pub is_suspense: bool,
427
428    /// Trading partner company code (for intercompany)
429    pub trading_partner: Option<String>,
430
431    /// Quantity (for quantity-based postings)
432    #[serde(default, with = "rust_decimal::serde::str_option")]
433    pub quantity: Option<Decimal>,
434
435    /// Unit of measure
436    pub unit_of_measure: Option<String>,
437
438    /// Unit (alias for unit_of_measure for compatibility)
439    #[serde(default)]
440    pub unit: Option<String>,
441
442    /// Project code
443    #[serde(default)]
444    pub project_code: Option<String>,
445}
446
447impl JournalEntryLine {
448    /// Create a new debit line item.
449    pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
450        Self {
451            document_id,
452            line_number,
453            gl_account: gl_account.clone(),
454            account_code: gl_account,
455            account_description: None,
456            debit_amount: amount,
457            credit_amount: Decimal::ZERO,
458            local_amount: amount,
459            group_amount: None,
460            cost_center: None,
461            profit_center: None,
462            segment: None,
463            functional_area: None,
464            line_text: None,
465            text: None,
466            reference: None,
467            value_date: None,
468            tax_code: None,
469            tax_amount: None,
470            assignment: None,
471            offsetting_account: None,
472            is_suspense: false,
473            trading_partner: None,
474            quantity: None,
475            unit_of_measure: None,
476            unit: None,
477            project_code: None,
478        }
479    }
480
481    /// Create a new credit line item.
482    pub fn credit(
483        document_id: Uuid,
484        line_number: u32,
485        gl_account: String,
486        amount: Decimal,
487    ) -> Self {
488        Self {
489            document_id,
490            line_number,
491            gl_account: gl_account.clone(),
492            account_code: gl_account,
493            account_description: None,
494            debit_amount: Decimal::ZERO,
495            credit_amount: amount,
496            local_amount: -amount,
497            group_amount: None,
498            cost_center: None,
499            profit_center: None,
500            segment: None,
501            functional_area: None,
502            line_text: None,
503            text: None,
504            reference: None,
505            value_date: None,
506            tax_code: None,
507            tax_amount: None,
508            assignment: None,
509            offsetting_account: None,
510            is_suspense: false,
511            trading_partner: None,
512            quantity: None,
513            unit_of_measure: None,
514            unit: None,
515            project_code: None,
516        }
517    }
518
519    /// Check if this is a debit posting.
520    pub fn is_debit(&self) -> bool {
521        self.debit_amount > Decimal::ZERO
522    }
523
524    /// Check if this is a credit posting.
525    pub fn is_credit(&self) -> bool {
526        self.credit_amount > Decimal::ZERO
527    }
528
529    /// Get the signed amount (positive for debit, negative for credit).
530    pub fn signed_amount(&self) -> Decimal {
531        self.debit_amount - self.credit_amount
532    }
533
534    // Convenience accessors for compatibility
535
536    /// Get the account code (alias for gl_account).
537    #[allow(clippy::misnamed_getters)]
538    pub fn account_code(&self) -> &str {
539        &self.gl_account
540    }
541
542    /// Get the account description (currently returns empty string as not stored).
543    pub fn account_description(&self) -> &str {
544        // Account descriptions are typically looked up from CoA, not stored per line
545        ""
546    }
547}
548
549impl Default for JournalEntryLine {
550    fn default() -> Self {
551        Self {
552            document_id: Uuid::nil(),
553            line_number: 0,
554            gl_account: String::new(),
555            account_code: String::new(),
556            account_description: None,
557            debit_amount: Decimal::ZERO,
558            credit_amount: Decimal::ZERO,
559            local_amount: Decimal::ZERO,
560            group_amount: None,
561            cost_center: None,
562            profit_center: None,
563            segment: None,
564            functional_area: None,
565            line_text: None,
566            text: None,
567            reference: None,
568            value_date: None,
569            tax_code: None,
570            tax_amount: None,
571            assignment: None,
572            offsetting_account: None,
573            is_suspense: false,
574            trading_partner: None,
575            quantity: None,
576            unit_of_measure: None,
577            unit: None,
578            project_code: None,
579        }
580    }
581}
582
583/// Complete journal entry with header and line items.
584///
585/// Represents a balanced double-entry bookkeeping transaction where
586/// total debits must equal total credits.
587#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct JournalEntry {
589    /// Header with document metadata
590    pub header: JournalEntryHeader,
591    /// Line items (debit and credit postings)
592    pub lines: Vec<JournalEntryLine>,
593}
594
595impl JournalEntry {
596    /// Create a new journal entry with header and empty lines.
597    pub fn new(header: JournalEntryHeader) -> Self {
598        Self {
599            header,
600            lines: Vec::new(),
601        }
602    }
603
604    /// Create a new journal entry with basic parameters (convenience constructor).
605    ///
606    /// This is a simplified constructor for backwards compatibility that creates
607    /// a journal entry with the specified document number, company code, posting date,
608    /// and description.
609    pub fn new_simple(
610        _document_number: String,
611        company_code: String,
612        posting_date: NaiveDate,
613        description: String,
614    ) -> Self {
615        let mut header = JournalEntryHeader::new(company_code, posting_date);
616        header.header_text = Some(description);
617        Self {
618            header,
619            lines: Vec::new(),
620        }
621    }
622
623    /// Add a line item to the journal entry.
624    pub fn add_line(&mut self, line: JournalEntryLine) {
625        self.lines.push(line);
626    }
627
628    /// Get the total debit amount.
629    pub fn total_debit(&self) -> Decimal {
630        self.lines.iter().map(|l| l.debit_amount).sum()
631    }
632
633    /// Get the total credit amount.
634    pub fn total_credit(&self) -> Decimal {
635        self.lines.iter().map(|l| l.credit_amount).sum()
636    }
637
638    /// Check if the journal entry is balanced (debits = credits).
639    pub fn is_balanced(&self) -> bool {
640        self.total_debit() == self.total_credit()
641    }
642
643    /// Get the out-of-balance amount (should be zero for valid entries).
644    pub fn balance_difference(&self) -> Decimal {
645        self.total_debit() - self.total_credit()
646    }
647
648    /// Get the number of line items.
649    pub fn line_count(&self) -> usize {
650        self.lines.len()
651    }
652
653    /// Check if the line count is even.
654    pub fn has_even_line_count(&self) -> bool {
655        self.lines.len() % 2 == 0
656    }
657
658    /// Get the count of debit and credit lines.
659    pub fn debit_credit_counts(&self) -> (usize, usize) {
660        let debits = self.lines.iter().filter(|l| l.is_debit()).count();
661        let credits = self.lines.iter().filter(|l| l.is_credit()).count();
662        (debits, credits)
663    }
664
665    /// Check if debit and credit line counts are equal.
666    pub fn has_equal_debit_credit_counts(&self) -> bool {
667        let (d, c) = self.debit_credit_counts();
668        d == c
669    }
670
671    /// Get unique GL accounts used in this entry.
672    pub fn unique_accounts(&self) -> Vec<&str> {
673        let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
674        accounts.sort();
675        accounts.dedup();
676        accounts
677    }
678
679    /// Check if any line posts to a suspense account.
680    pub fn has_suspense_posting(&self) -> bool {
681        self.lines.iter().any(|l| l.is_suspense)
682    }
683
684    // Convenience accessors for header fields
685
686    /// Get the company code.
687    pub fn company_code(&self) -> &str {
688        &self.header.company_code
689    }
690
691    /// Get the document number (document_id as string).
692    pub fn document_number(&self) -> String {
693        self.header.document_id.to_string()
694    }
695
696    /// Get the posting date.
697    pub fn posting_date(&self) -> NaiveDate {
698        self.header.posting_date
699    }
700
701    /// Get the document date.
702    pub fn document_date(&self) -> NaiveDate {
703        self.header.document_date
704    }
705
706    /// Get the fiscal year.
707    pub fn fiscal_year(&self) -> u16 {
708        self.header.fiscal_year
709    }
710
711    /// Get the fiscal period.
712    pub fn fiscal_period(&self) -> u8 {
713        self.header.fiscal_period
714    }
715
716    /// Get the currency.
717    pub fn currency(&self) -> &str {
718        &self.header.currency
719    }
720
721    /// Check if this entry is marked as fraud.
722    pub fn is_fraud(&self) -> bool {
723        self.header.is_fraud
724    }
725
726    /// Check if this entry has a SOD violation.
727    pub fn has_sod_violation(&self) -> bool {
728        self.header.sod_violation
729    }
730
731    /// Get the description (header text).
732    pub fn description(&self) -> Option<&str> {
733        self.header.header_text.as_deref()
734    }
735
736    /// Set the description (header text).
737    pub fn set_description(&mut self, description: String) {
738        self.header.header_text = Some(description);
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn test_balanced_entry() {
748        let header = JournalEntryHeader::new(
749            "1000".to_string(),
750            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
751        );
752        let mut entry = JournalEntry::new(header);
753
754        entry.add_line(JournalEntryLine::debit(
755            entry.header.document_id,
756            1,
757            "100000".to_string(),
758            Decimal::from(1000),
759        ));
760        entry.add_line(JournalEntryLine::credit(
761            entry.header.document_id,
762            2,
763            "200000".to_string(),
764            Decimal::from(1000),
765        ));
766
767        assert!(entry.is_balanced());
768        assert_eq!(entry.line_count(), 2);
769        assert!(entry.has_even_line_count());
770        assert!(entry.has_equal_debit_credit_counts());
771    }
772
773    #[test]
774    fn test_unbalanced_entry() {
775        let header = JournalEntryHeader::new(
776            "1000".to_string(),
777            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
778        );
779        let mut entry = JournalEntry::new(header);
780
781        entry.add_line(JournalEntryLine::debit(
782            entry.header.document_id,
783            1,
784            "100000".to_string(),
785            Decimal::from(1000),
786        ));
787        entry.add_line(JournalEntryLine::credit(
788            entry.header.document_id,
789            2,
790            "200000".to_string(),
791            Decimal::from(500),
792        ));
793
794        assert!(!entry.is_balanced());
795        assert_eq!(entry.balance_difference(), Decimal::from(500));
796    }
797}