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