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    /// SP3.6 — canonical SAP source code drawn from industry priors
297    /// (`KR`, `RV`, `DZ`, `SA`, …).  `Some` only when priors are loaded;
298    /// `None` on the priors-disabled path (in which case the CSV `source`
299    /// column falls back to the `TransactionSource` debug label).
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub sap_source_code: Option<String>,
302
303    /// SP3.9 — JE-level trading partner. When priors are loaded, drawn
304    /// once per JE from `per_source_attribute[sap_source_code]["trading_partner"]`
305    /// and inherited by all lines. Matches corpus SAP semantics
306    /// (one TP per document).
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub trading_partner: Option<String>,
309
310    /// Business process reference
311    pub business_process: Option<BusinessProcess>,
312
313    /// Ledger (0L = Leading Ledger)
314    pub ledger: String,
315
316    /// Is this entry part of a fraud scenario
317    pub is_fraud: bool,
318
319    /// Fraud type if applicable
320    pub fraud_type: Option<FraudType>,
321
322    /// Whether `is_fraud` was propagated from a source document (document-level
323    /// fraud injection) rather than injected directly onto this line.
324    /// Used by downstream consumers to distinguish scheme-level from
325    /// slip-level fraud patterns.
326    #[serde(default)]
327    pub is_fraud_propagated: bool,
328
329    /// When `is_fraud_propagated` is true, the external document ID
330    /// (PO number, invoice number, etc.) that was the origin of the fraud.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub fraud_source_document_id: Option<String>,
333
334    // --- Anomaly Tracking Fields ---
335    /// Whether this entry has an injected anomaly
336    #[serde(default)]
337    pub is_anomaly: bool,
338
339    /// Unique anomaly identifier for label linkage
340    #[serde(default)]
341    pub anomaly_id: Option<String>,
342
343    /// Type of anomaly if applicable (serialized enum name)
344    #[serde(default)]
345    pub anomaly_type: Option<String>,
346
347    /// Simulation batch ID for traceability
348    pub batch_id: Option<Uuid>,
349
350    // --- ISA 240 Audit Flags ---
351    /// Whether this is a manual journal entry (vs automated/system-generated).
352    /// Manual entries are higher fraud risk per ISA 240.32(a).
353    #[serde(default)]
354    pub is_manual: bool,
355
356    /// Whether this entry was posted after the period-end close date.
357    /// Post-closing entries require additional scrutiny per ISA 240.
358    #[serde(default)]
359    pub is_post_close: bool,
360
361    /// Source system/module that originated this entry.
362    /// Examples: "SAP-FI", "SAP-MM", "SAP-SD", "manual", "interface", "spreadsheet"
363    #[serde(default)]
364    pub source_system: String,
365
366    /// Timestamp when the entry was created (may differ from posting_date).
367    /// For automated entries this is typically before posting_date; for manual
368    /// entries created_date and posting_date are often on the same day.
369    #[serde(default, with = "crate::serde_timestamp::naive::option")]
370    pub created_date: Option<NaiveDateTime>,
371
372    // --- Internal Controls / SOX Compliance Fields ---
373    /// Internal control IDs that apply to this transaction
374    #[serde(default)]
375    pub control_ids: Vec<String>,
376
377    /// Whether this is a SOX-relevant transaction
378    #[serde(default)]
379    pub sox_relevant: bool,
380
381    /// Control status for this transaction
382    #[serde(default)]
383    pub control_status: super::internal_control::ControlStatus,
384
385    /// Whether a Segregation of Duties violation occurred
386    #[serde(default)]
387    pub sod_violation: bool,
388
389    /// Type of SoD conflict if violation occurred
390    #[serde(default)]
391    pub sod_conflict_type: Option<super::sod::SodConflictType>,
392
393    /// Whether this is a consolidation elimination entry
394    #[serde(default)]
395    pub is_elimination: bool,
396
397    // --- Intercompany Pair Linkage (group audit v5.0, Task 3.1) ---
398    /// Intercompany pair identifier. Set on both seller-side and buyer-side JEs
399    /// that are two halves of the same IC transaction. Aggregate-phase matching
400    /// joins JEs on this value. `None` for non-IC transactions.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub ic_pair_id: Option<crate::models::IcPairId>,
403
404    /// Entity code of the IC counterparty. For a seller JE, this is the
405    /// buyer's entity; for a buyer JE, the seller's. `None` for non-IC transactions.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub ic_partner_entity: Option<String>,
408
409    // --- Approval Workflow ---
410    /// Approval workflow for high-value transactions
411    #[serde(default)]
412    pub approval_workflow: Option<ApprovalWorkflow>,
413
414    // --- Source Document + Approval Tracking ---
415    /// Typed reference to the source document (derived from `reference` field prefix)
416    #[serde(default)]
417    pub source_document: Option<DocumentRef>,
418    /// Employee ID of the approver (for SOD analysis)
419    #[serde(default)]
420    pub approved_by: Option<String>,
421    /// Date of approval
422    #[serde(default)]
423    pub approval_date: Option<NaiveDate>,
424
425    // --- OCPM (Object-Centric Process Mining) Traceability ---
426    /// OCPM event IDs that triggered this journal entry
427    #[serde(default)]
428    pub ocpm_event_ids: Vec<Uuid>,
429
430    /// OCPM object IDs involved in this journal entry
431    #[serde(default)]
432    pub ocpm_object_ids: Vec<Uuid>,
433
434    /// OCPM case ID for process instance tracking
435    #[serde(default)]
436    pub ocpm_case_id: Option<Uuid>,
437}
438
439impl JournalEntryHeader {
440    /// Create a new journal entry header with default values.
441    pub fn new(company_code: String, posting_date: NaiveDate) -> Self {
442        Self {
443            document_id: Uuid::now_v7(),
444            company_code,
445            fiscal_year: posting_date.year() as u16,
446            fiscal_period: posting_date.month() as u8,
447            posting_date,
448            document_date: posting_date,
449            created_at: Utc::now(),
450            document_type: "SA".to_string(),
451            currency: "USD".to_string(),
452            exchange_rate: Decimal::ONE,
453            reference: None,
454            header_text: None,
455            created_by: "SYSTEM".to_string(),
456            user_persona: "automated_system".to_string(),
457            source: TransactionSource::Automated,
458            sap_source_code: None,
459            trading_partner: None,
460            business_process: Some(BusinessProcess::R2R),
461            ledger: "0L".to_string(),
462            is_fraud: false,
463            fraud_type: None,
464            is_fraud_propagated: false,
465            fraud_source_document_id: None,
466            // Anomaly tracking
467            is_anomaly: false,
468            anomaly_id: None,
469            anomaly_type: None,
470            batch_id: None,
471            // ISA 240 audit flags
472            is_manual: false,
473            is_post_close: false,
474            source_system: String::new(),
475            created_date: None,
476            // Internal Controls / SOX fields
477            control_ids: Vec::new(),
478            sox_relevant: false,
479            control_status: super::internal_control::ControlStatus::default(),
480            sod_violation: false,
481            sod_conflict_type: None,
482            // Consolidation elimination flag
483            is_elimination: false,
484            // Intercompany pair linkage (group audit v5.0, Task 3.1)
485            ic_pair_id: None,
486            ic_partner_entity: None,
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    /// Create a new journal entry header with a deterministic document ID.
501    ///
502    /// Used for reproducible generation where the document ID is derived
503    /// from a seed and counter.
504    pub fn with_deterministic_id(
505        company_code: String,
506        posting_date: NaiveDate,
507        document_id: Uuid,
508    ) -> Self {
509        Self {
510            document_id,
511            company_code,
512            fiscal_year: posting_date.year() as u16,
513            fiscal_period: posting_date.month() as u8,
514            posting_date,
515            document_date: posting_date,
516            created_at: Utc::now(),
517            document_type: "SA".to_string(),
518            currency: "USD".to_string(),
519            exchange_rate: Decimal::ONE,
520            reference: None,
521            header_text: None,
522            created_by: "SYSTEM".to_string(),
523            user_persona: "automated_system".to_string(),
524            source: TransactionSource::Automated,
525            sap_source_code: None,
526            trading_partner: None,
527            business_process: Some(BusinessProcess::R2R),
528            ledger: "0L".to_string(),
529            is_fraud: false,
530            fraud_type: None,
531            is_fraud_propagated: false,
532            fraud_source_document_id: None,
533            // Anomaly tracking
534            is_anomaly: false,
535            anomaly_id: None,
536            anomaly_type: None,
537            batch_id: None,
538            // ISA 240 audit flags
539            is_manual: false,
540            is_post_close: false,
541            source_system: String::new(),
542            created_date: None,
543            // Internal Controls / SOX fields
544            control_ids: Vec::new(),
545            sox_relevant: false,
546            control_status: super::internal_control::ControlStatus::default(),
547            sod_violation: false,
548            sod_conflict_type: None,
549            // Consolidation elimination flag
550            is_elimination: false,
551            // Intercompany pair linkage (group audit v5.0, Task 3.1)
552            ic_pair_id: None,
553            ic_partner_entity: None,
554            // Approval workflow
555            approval_workflow: None,
556            // Source document + approval tracking
557            source_document: None,
558            approved_by: None,
559            approval_date: None,
560            // OCPM traceability
561            ocpm_event_ids: Vec::new(),
562            ocpm_object_ids: Vec::new(),
563            ocpm_case_id: None,
564        }
565    }
566
567    /// Look up this header's source document id in a fraud map (keyed by
568    /// document id → fraud type) and, if found, mark this header as
569    /// propagated fraud. Returns `true` when the header was tagged.
570    ///
571    /// Unlike direct line-level fraud injection, this sets
572    /// `is_fraud_propagated = true` and records `fraud_source_document_id`
573    /// so downstream consumers can distinguish scheme-level fraud (many
574    /// correlated lines tagged by one document) from slip-level fraud (a
575    /// single tagged line with no document origin).
576    pub fn propagate_fraud_from_documents(
577        &mut self,
578        fraud_map: &std::collections::HashMap<String, crate::models::FraudType>,
579    ) -> bool {
580        let doc_id = match self
581            .source_document
582            .as_ref()
583            .and_then(DocumentRef::document_id)
584        {
585            Some(id) => id,
586            None => return false,
587        };
588        if let Some(ft) = fraud_map.get(doc_id) {
589            self.is_fraud = true;
590            self.fraud_type = Some(*ft);
591            self.is_fraud_propagated = true;
592            self.fraud_source_document_id = Some(doc_id.to_string());
593            return true;
594        }
595        false
596    }
597}
598
599use chrono::Datelike;
600
601/// Individual line item within a journal entry.
602///
603/// Each line represents a debit or credit posting to a specific GL account.
604/// Line items must be balanced within a journal entry (sum of debits = sum of credits).
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct JournalEntryLine {
607    /// Parent document ID (matches header)
608    pub document_id: Uuid,
609
610    /// Line item number within document (1-based)
611    pub line_number: u32,
612
613    /// GL account number
614    pub gl_account: String,
615
616    /// Account code (alias for gl_account for compatibility)
617    #[serde(default)]
618    pub account_code: String,
619
620    /// Account description (for display)
621    #[serde(default)]
622    pub account_description: Option<String>,
623
624    /// Debit amount in transaction currency (positive or zero)
625    #[serde(with = "crate::serde_decimal")]
626    pub debit_amount: Decimal,
627
628    /// Credit amount in transaction currency (positive or zero)
629    #[serde(with = "crate::serde_decimal")]
630    pub credit_amount: Decimal,
631
632    /// Amount in local/company currency
633    #[serde(with = "crate::serde_decimal")]
634    pub local_amount: Decimal,
635
636    /// Amount in group currency (for consolidation)
637    #[serde(default, with = "crate::serde_decimal::option")]
638    pub group_amount: Option<Decimal>,
639
640    /// **SOTA-4** Document/transaction-currency amount (SAP WRBTR) when the JE
641    /// posts in a foreign currency. `debit_amount`/`credit_amount`/`local_amount`
642    /// remain the company-ledger amount (SAP DMBTR — the ledger and trial balance
643    /// aggregate these, unchanged), while this carries the original foreign value
644    /// at `header.currency` / `header.exchange_rate`. `None` for company-currency
645    /// JEs. Additive: ledger coherence is untouched.
646    #[serde(default, with = "crate::serde_decimal::option")]
647    pub transaction_amount: Option<Decimal>,
648
649    /// Cost center assignment
650    pub cost_center: Option<String>,
651
652    /// Profit center assignment
653    pub profit_center: Option<String>,
654
655    /// Business unit / division assignment. An organisational dimension that
656    /// rolls up cost centers (a few BUs carry most postings, as in the corpus).
657    /// `#[serde(default)]` so older JSON without the field still deserialises.
658    #[serde(default)]
659    pub business_unit: Option<String>,
660
661    /// Segment for segment reporting
662    pub segment: Option<String>,
663
664    /// Functional area
665    pub functional_area: Option<String>,
666
667    /// Line item text/description
668    pub line_text: Option<String>,
669
670    /// Text field (alias for line_text for compatibility)
671    #[serde(default)]
672    pub text: Option<String>,
673
674    /// Reference field
675    #[serde(default)]
676    pub reference: Option<String>,
677
678    /// Value date (for interest calculations)
679    #[serde(default)]
680    pub value_date: Option<NaiveDate>,
681
682    /// Tax code
683    pub tax_code: Option<String>,
684
685    /// Tax amount
686    #[serde(default, with = "crate::serde_decimal::option")]
687    pub tax_amount: Option<Decimal>,
688
689    /// Assignment field (for account assignment)
690    pub assignment: Option<String>,
691
692    /// Reference to offsetting account (for network generation)
693    pub offsetting_account: Option<String>,
694
695    /// Is this posting to a suspense/clearing account
696    pub is_suspense: bool,
697
698    /// Trading partner company code (for intercompany)
699    pub trading_partner: Option<String>,
700
701    /// Quantity (for quantity-based postings)
702    #[serde(default, with = "crate::serde_decimal::option")]
703    pub quantity: Option<Decimal>,
704
705    /// Unit of measure
706    pub unit_of_measure: Option<String>,
707
708    /// Unit (alias for unit_of_measure for compatibility)
709    #[serde(default)]
710    pub unit: Option<String>,
711
712    /// Project code
713    #[serde(default)]
714    pub project_code: Option<String>,
715
716    /// Auxiliary account number (FEC column 7: Numéro de compte auxiliaire)
717    /// Populated for AP/AR lines under French GAAP with the business partner ID.
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub auxiliary_account_number: Option<String>,
720
721    /// Auxiliary account label (FEC column 8: Libellé de compte auxiliaire)
722    /// Populated for AP/AR lines under French GAAP with the business partner name.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub auxiliary_account_label: Option<String>,
725
726    /// Lettrage code (FEC column 14: Lettrage)
727    /// Links offset postings in a completed document chain (e.g. invoice ↔ payment).
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub lettrage: Option<String>,
730
731    /// Lettrage date (FEC column 15: Date de lettrage)
732    /// Date when the matching was performed (typically the payment posting date).
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub lettrage_date: Option<NaiveDate>,
735
736    /// Stable per-line transaction identifier.
737    ///
738    /// Deterministically derived from `(document_id, line_number)` so it stays
739    /// consistent across regenerations of the same dataset. Populated lazily
740    /// at write time from [`JournalEntryLine::derive_transaction_id`] when
741    /// not already set.
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub transaction_id: Option<String>,
744
745    /// **v5.8.0** — predecessor line in a document booking chain.
746    ///
747    /// Set to the `transaction_id` of the line in a prior journal entry
748    /// that this line directly succeeds in a document chain (e.g. a
749    /// payment line's predecessor is the corresponding line in the
750    /// vendor invoice JE; a goods-receipt line's predecessor is the
751    /// matching line in the purchase-order JE). `None` for purely-GL
752    /// adjustments, period-close postings, payroll, or the root
753    /// (first) document in a chain.
754    ///
755    /// Used by the `graphs/je_network.csv` flat edge-list output to
756    /// trace booking chains across JEs without joining against
757    /// `document_references.json`.
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub predecessor_line_id: Option<String>,
760}
761
762impl JournalEntryLine {
763    /// Create a new debit line item.
764    #[inline]
765    pub fn debit(document_id: Uuid, line_number: u32, gl_account: String, amount: Decimal) -> Self {
766        Self {
767            document_id,
768            line_number,
769            gl_account: gl_account.clone(),
770            account_code: gl_account,
771            account_description: None,
772            debit_amount: amount,
773            credit_amount: Decimal::ZERO,
774            local_amount: amount,
775            group_amount: None,
776            transaction_amount: None,
777            cost_center: None,
778            profit_center: None,
779            business_unit: None,
780            segment: None,
781            functional_area: None,
782            line_text: None,
783            text: None,
784            reference: None,
785            value_date: None,
786            tax_code: None,
787            tax_amount: None,
788            assignment: None,
789            offsetting_account: None,
790            is_suspense: false,
791            trading_partner: None,
792            quantity: None,
793            unit_of_measure: None,
794            unit: None,
795            project_code: None,
796            auxiliary_account_number: None,
797            auxiliary_account_label: None,
798            lettrage: None,
799            lettrage_date: None,
800            transaction_id: None,
801            predecessor_line_id: None,
802        }
803    }
804
805    /// Create a new credit line item.
806    #[inline]
807    pub fn credit(
808        document_id: Uuid,
809        line_number: u32,
810        gl_account: String,
811        amount: Decimal,
812    ) -> Self {
813        Self {
814            document_id,
815            line_number,
816            gl_account: gl_account.clone(),
817            account_code: gl_account,
818            account_description: None,
819            debit_amount: Decimal::ZERO,
820            credit_amount: amount,
821            local_amount: -amount,
822            group_amount: None,
823            transaction_amount: None,
824            cost_center: None,
825            profit_center: None,
826            business_unit: None,
827            segment: None,
828            functional_area: None,
829            line_text: None,
830            text: None,
831            reference: None,
832            value_date: None,
833            tax_code: None,
834            tax_amount: None,
835            assignment: None,
836            offsetting_account: None,
837            is_suspense: false,
838            trading_partner: None,
839            quantity: None,
840            unit_of_measure: None,
841            unit: None,
842            project_code: None,
843            auxiliary_account_number: None,
844            auxiliary_account_label: None,
845            lettrage: None,
846            lettrage_date: None,
847            transaction_id: None,
848            predecessor_line_id: None,
849        }
850    }
851
852    /// Check if this is a debit posting.
853    #[inline]
854    pub fn is_debit(&self) -> bool {
855        self.debit_amount > Decimal::ZERO
856    }
857
858    /// Check if this is a credit posting.
859    #[inline]
860    pub fn is_credit(&self) -> bool {
861        self.credit_amount > Decimal::ZERO
862    }
863
864    /// Get the signed amount (positive for debit, negative for credit).
865    #[inline]
866    pub fn signed_amount(&self) -> Decimal {
867        self.debit_amount - self.credit_amount
868    }
869
870    /// Derive a stable per-line transaction id from `(document_id, line_number)`.
871    ///
872    /// Deterministic: the same input always returns the same id, so the
873    /// value is stable across regenerations of the same dataset and across
874    /// crate versions. Uses UUID v5 (SHA-1) under `Uuid::NAMESPACE_OID`.
875    pub fn derive_transaction_id(document_id: Uuid, line_number: u32) -> String {
876        let mut input = Vec::with_capacity(20);
877        input.extend_from_slice(document_id.as_bytes());
878        input.extend_from_slice(&line_number.to_le_bytes());
879        Uuid::new_v5(&Uuid::NAMESPACE_OID, &input).to_string()
880    }
881
882    /// Populate `transaction_id` from `(document_id, line_number)` if unset.
883    pub fn ensure_transaction_id(&mut self) {
884        if self.transaction_id.is_none() {
885            self.transaction_id = Some(Self::derive_transaction_id(
886                self.document_id,
887                self.line_number,
888            ));
889        }
890    }
891
892    // Convenience accessors for compatibility
893
894    /// Get the account code (alias for gl_account).
895    #[allow(clippy::misnamed_getters)]
896    pub fn account_code(&self) -> &str {
897        &self.gl_account
898    }
899
900    /// Get the account description (currently returns empty string as not stored).
901    pub fn account_description(&self) -> &str {
902        // Account descriptions are typically looked up from CoA, not stored per line
903        ""
904    }
905}
906
907impl Default for JournalEntryLine {
908    fn default() -> Self {
909        Self {
910            document_id: Uuid::nil(),
911            line_number: 0,
912            gl_account: String::new(),
913            account_code: String::new(),
914            account_description: None,
915            debit_amount: Decimal::ZERO,
916            credit_amount: Decimal::ZERO,
917            local_amount: Decimal::ZERO,
918            group_amount: None,
919            transaction_amount: None,
920            cost_center: None,
921            profit_center: None,
922            business_unit: None,
923            segment: None,
924            functional_area: None,
925            line_text: None,
926            text: None,
927            reference: None,
928            value_date: None,
929            tax_code: None,
930            tax_amount: None,
931            assignment: None,
932            offsetting_account: None,
933            is_suspense: false,
934            trading_partner: None,
935            quantity: None,
936            unit_of_measure: None,
937            unit: None,
938            project_code: None,
939            auxiliary_account_number: None,
940            auxiliary_account_label: None,
941            lettrage: None,
942            lettrage_date: None,
943            transaction_id: None,
944            predecessor_line_id: None,
945        }
946    }
947}
948
949/// Complete journal entry with header and line items.
950///
951/// Represents a balanced double-entry bookkeeping transaction where
952/// total debits must equal total credits.
953///
954/// Uses `SmallVec<[JournalEntryLine; 4]>` for line items: entries with
955/// 4 or fewer lines (the common case) are stored inline on the stack,
956/// avoiding heap allocation. Entries with more lines spill to the heap
957/// transparently.
958#[derive(Debug, Clone, Serialize, Deserialize)]
959pub struct JournalEntry {
960    /// Header with document metadata
961    pub header: JournalEntryHeader,
962    /// Line items (debit and credit postings).
963    /// Inline for ≤4 lines (common case), heap-allocated for >4.
964    pub lines: SmallVec<[JournalEntryLine; 4]>,
965}
966
967impl JournalEntry {
968    /// Create a new journal entry with header and empty lines.
969    pub fn new(header: JournalEntryHeader) -> Self {
970        Self {
971            header,
972            lines: SmallVec::new(),
973        }
974    }
975
976    /// Create a new journal entry with basic parameters (convenience constructor).
977    ///
978    /// This is a simplified constructor for backwards compatibility that creates
979    /// a journal entry with the specified document number, company code, posting date,
980    /// and description.
981    pub fn new_simple(
982        document_number: String,
983        company_code: String,
984        posting_date: NaiveDate,
985        description: String,
986    ) -> Self {
987        let mut header = JournalEntryHeader::new(company_code, posting_date);
988        header.header_text = Some(description);
989        header.reference = Some(document_number);
990        Self {
991            header,
992            lines: SmallVec::new(),
993        }
994    }
995
996    /// Add a line item to the journal entry.
997    ///
998    /// Automatically sets the line's `document_id` to match the header's `document_id`.
999    #[inline]
1000    pub fn add_line(&mut self, mut line: JournalEntryLine) {
1001        line.document_id = self.header.document_id;
1002        self.lines.push(line);
1003    }
1004
1005    /// Get the total debit amount.
1006    pub fn total_debit(&self) -> Decimal {
1007        self.lines.iter().map(|l| l.debit_amount).sum()
1008    }
1009
1010    /// Get the total credit amount.
1011    pub fn total_credit(&self) -> Decimal {
1012        self.lines.iter().map(|l| l.credit_amount).sum()
1013    }
1014
1015    /// Check if the journal entry is balanced (debits = credits).
1016    pub fn is_balanced(&self) -> bool {
1017        self.total_debit() == self.total_credit()
1018    }
1019
1020    /// Get the out-of-balance amount (should be zero for valid entries).
1021    pub fn balance_difference(&self) -> Decimal {
1022        self.total_debit() - self.total_credit()
1023    }
1024
1025    /// Get the number of line items.
1026    pub fn line_count(&self) -> usize {
1027        self.lines.len()
1028    }
1029
1030    /// Check if the line count is even.
1031    pub fn has_even_line_count(&self) -> bool {
1032        self.lines.len().is_multiple_of(2)
1033    }
1034
1035    /// Get the count of debit and credit lines.
1036    pub fn debit_credit_counts(&self) -> (usize, usize) {
1037        let debits = self.lines.iter().filter(|l| l.is_debit()).count();
1038        let credits = self.lines.iter().filter(|l| l.is_credit()).count();
1039        (debits, credits)
1040    }
1041
1042    /// Check if debit and credit line counts are equal.
1043    pub fn has_equal_debit_credit_counts(&self) -> bool {
1044        let (d, c) = self.debit_credit_counts();
1045        d == c
1046    }
1047
1048    /// Get unique GL accounts used in this entry.
1049    pub fn unique_accounts(&self) -> Vec<&str> {
1050        let mut accounts: Vec<&str> = self.lines.iter().map(|l| l.gl_account.as_str()).collect();
1051        accounts.sort();
1052        accounts.dedup();
1053        accounts
1054    }
1055
1056    /// Check if any line posts to a suspense account.
1057    pub fn has_suspense_posting(&self) -> bool {
1058        self.lines.iter().any(|l| l.is_suspense)
1059    }
1060
1061    // Convenience accessors for header fields
1062
1063    /// Get the company code.
1064    pub fn company_code(&self) -> &str {
1065        &self.header.company_code
1066    }
1067
1068    /// Get the document number (document_id as string).
1069    pub fn document_number(&self) -> String {
1070        self.header.document_id.to_string()
1071    }
1072
1073    /// Get the posting date.
1074    pub fn posting_date(&self) -> NaiveDate {
1075        self.header.posting_date
1076    }
1077
1078    /// Get the document date.
1079    pub fn document_date(&self) -> NaiveDate {
1080        self.header.document_date
1081    }
1082
1083    /// Get the fiscal year.
1084    pub fn fiscal_year(&self) -> u16 {
1085        self.header.fiscal_year
1086    }
1087
1088    /// Get the fiscal period.
1089    pub fn fiscal_period(&self) -> u8 {
1090        self.header.fiscal_period
1091    }
1092
1093    /// Get the currency.
1094    pub fn currency(&self) -> &str {
1095        &self.header.currency
1096    }
1097
1098    /// Check if this entry is marked as fraud.
1099    pub fn is_fraud(&self) -> bool {
1100        self.header.is_fraud
1101    }
1102
1103    /// Check if this entry has a SOD violation.
1104    pub fn has_sod_violation(&self) -> bool {
1105        self.header.sod_violation
1106    }
1107
1108    /// Get the description (header text).
1109    pub fn description(&self) -> Option<&str> {
1110        self.header.header_text.as_deref()
1111    }
1112
1113    /// Set the description (header text).
1114    pub fn set_description(&mut self, description: String) {
1115        self.header.header_text = Some(description);
1116    }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    #[test]
1124    fn test_balanced_entry() {
1125        let header = JournalEntryHeader::new(
1126            "1000".to_string(),
1127            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1128        );
1129        let mut entry = JournalEntry::new(header);
1130
1131        entry.add_line(JournalEntryLine::debit(
1132            entry.header.document_id,
1133            1,
1134            "100000".to_string(),
1135            Decimal::from(1000),
1136        ));
1137        entry.add_line(JournalEntryLine::credit(
1138            entry.header.document_id,
1139            2,
1140            "200000".to_string(),
1141            Decimal::from(1000),
1142        ));
1143
1144        assert!(entry.is_balanced());
1145        assert_eq!(entry.line_count(), 2);
1146        assert!(entry.has_even_line_count());
1147        assert!(entry.has_equal_debit_credit_counts());
1148    }
1149
1150    #[test]
1151    fn test_unbalanced_entry() {
1152        let header = JournalEntryHeader::new(
1153            "1000".to_string(),
1154            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1155        );
1156        let mut entry = JournalEntry::new(header);
1157
1158        entry.add_line(JournalEntryLine::debit(
1159            entry.header.document_id,
1160            1,
1161            "100000".to_string(),
1162            Decimal::from(1000),
1163        ));
1164        entry.add_line(JournalEntryLine::credit(
1165            entry.header.document_id,
1166            2,
1167            "200000".to_string(),
1168            Decimal::from(500),
1169        ));
1170
1171        assert!(!entry.is_balanced());
1172        assert_eq!(entry.balance_difference(), Decimal::from(500));
1173    }
1174}