Skip to main content

datasynth_core/models/
ledger_coherence.rs

1//! Ledger Coherence Report — a first-class integrity self-validation output.
2//!
3//! Proves the generated ledger is internally coherent: every journal entry
4//! balances (debits = credits), the ledger as a whole nets to zero, and (when
5//! trial balances are supplied by the runtime) each period's trial balance
6//! balances. This doubles as a regression guard for engine-level coherence
7//! issues (e.g. trial-balance imbalance in chain mode).
8//!
9//! The core (`from_entries`) is pure and depends only on journal entries; the
10//! runtime augments the report with per-period trial-balance checks.
11
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14
15use super::JournalEntry;
16
17/// A journal entry whose debits and credits do not balance.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct UnbalancedJeRecord {
20    pub document_id: String,
21    pub company_code: String,
22    /// Decimal serialized as a string (no IEEE-754 drift).
23    pub total_debit: String,
24    pub total_credit: String,
25    pub difference: String,
26}
27
28/// Net activity for a single GL account across the ledger.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30pub struct AccountActivity {
31    pub gl_account: String,
32    pub debit_total: String,
33    pub credit_total: String,
34    pub net: String,
35    pub line_count: usize,
36}
37
38/// Per-period trial-balance self-balance check (populated by the runtime).
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct TbPeriodCheck {
41    pub fiscal_year: u16,
42    pub fiscal_period: u8,
43    pub total_debit_balance: String,
44    pub total_credit_balance: String,
45    pub difference: String,
46    pub balanced: bool,
47}
48
49/// Integrity self-validation report for a generated ledger.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct LedgerCoherenceReport {
52    pub je_count: usize,
53    pub line_count: usize,
54    pub balanced_je_count: usize,
55    pub unbalanced_je_count: usize,
56    /// Unbalanced entries (capped to keep the report bounded).
57    pub unbalanced_jes: Vec<UnbalancedJeRecord>,
58    pub all_jes_balanced: bool,
59    pub total_debits: String,
60    pub total_credits: String,
61    /// True when total debits == total credits across the whole ledger.
62    pub ledger_nets_to_zero: bool,
63    pub distinct_accounts: usize,
64    pub distinct_companies: usize,
65    /// `[min_posting_date, max_posting_date]` (ISO) when any entries exist.
66    pub period_span: Option<[String; 2]>,
67    /// Top accounts by absolute net activity (capped).
68    pub top_account_activity: Vec<AccountActivity>,
69    /// Per-period trial-balance checks (empty until the runtime populates them).
70    pub tb_period_checks: Vec<TbPeriodCheck>,
71    /// True when every supplied period trial balance balances (vacuously true
72    /// when no trial balances were supplied).
73    pub all_tbs_balanced: bool,
74}
75
76impl LedgerCoherenceReport {
77    /// Default cap on the number of unbalanced JEs / top accounts listed.
78    pub const DEFAULT_LIST_CAP: usize = 200;
79
80    /// Compute the coherence report from journal entries alone. The trial-balance
81    /// section is left empty (the runtime augments it via [`Self::with_tb_checks`]).
82    pub fn from_entries(entries: &[JournalEntry], list_cap: usize) -> Self {
83        use std::collections::{BTreeMap, BTreeSet};
84
85        let mut line_count = 0usize;
86        let mut balanced = 0usize;
87        let mut unbalanced: Vec<UnbalancedJeRecord> = Vec::new();
88        let mut total_debits = Decimal::ZERO;
89        let mut total_credits = Decimal::ZERO;
90        let mut companies: BTreeSet<&str> = BTreeSet::new();
91        // gl_account -> (debit, credit, line_count)
92        let mut activity: BTreeMap<String, (Decimal, Decimal, usize)> = BTreeMap::new();
93        let mut min_date = None;
94        let mut max_date = None;
95
96        for je in entries {
97            line_count += je.lines.len();
98            let td = je.total_debit();
99            let tc = je.total_credit();
100            total_debits += td;
101            total_credits += tc;
102            companies.insert(je.header.company_code.as_str());
103
104            let d = je.header.posting_date;
105            min_date = Some(min_date.map_or(d, |m: chrono::NaiveDate| m.min(d)));
106            max_date = Some(max_date.map_or(d, |m: chrono::NaiveDate| m.max(d)));
107
108            if je.is_balanced() {
109                balanced += 1;
110            } else if unbalanced.len() < list_cap {
111                unbalanced.push(UnbalancedJeRecord {
112                    document_id: je.header.document_id.to_string(),
113                    company_code: je.header.company_code.clone(),
114                    total_debit: td.to_string(),
115                    total_credit: tc.to_string(),
116                    difference: je.balance_difference().to_string(),
117                });
118            }
119
120            for line in &je.lines {
121                let e = activity.entry(line.gl_account.clone()).or_insert((
122                    Decimal::ZERO,
123                    Decimal::ZERO,
124                    0,
125                ));
126                e.0 += line.debit_amount;
127                e.1 += line.credit_amount;
128                e.2 += 1;
129            }
130        }
131
132        let unbalanced_je_count = entries.len() - balanced;
133        let distinct_accounts = activity.len();
134
135        // Top accounts by absolute net activity.
136        let mut acts: Vec<AccountActivity> = activity
137            .into_iter()
138            .map(|(acct, (d, c, n))| AccountActivity {
139                gl_account: acct,
140                debit_total: d.to_string(),
141                credit_total: c.to_string(),
142                net: (d - c).to_string(),
143                line_count: n,
144            })
145            .collect();
146        acts.sort_by(|a, b| {
147            let an = a.net.parse::<Decimal>().unwrap_or_default().abs();
148            let bn = b.net.parse::<Decimal>().unwrap_or_default().abs();
149            bn.cmp(&an)
150        });
151        acts.truncate(list_cap);
152
153        let period_span = match (min_date, max_date) {
154            (Some(a), Some(b)) => Some([a.to_string(), b.to_string()]),
155            _ => None,
156        };
157
158        Self {
159            je_count: entries.len(),
160            line_count,
161            balanced_je_count: balanced,
162            unbalanced_je_count,
163            unbalanced_jes: unbalanced,
164            all_jes_balanced: unbalanced_je_count == 0,
165            total_debits: total_debits.to_string(),
166            total_credits: total_credits.to_string(),
167            ledger_nets_to_zero: total_debits == total_credits,
168            distinct_accounts,
169            distinct_companies: companies.len(),
170            period_span,
171            top_account_activity: acts,
172            tb_period_checks: Vec::new(),
173            all_tbs_balanced: true,
174        }
175    }
176
177    /// Attach per-period trial-balance self-balance checks. `tbs` is an iterator
178    /// of `(fiscal_year, fiscal_period, total_debit_balance, total_credit_balance)`.
179    pub fn with_tb_checks(
180        mut self,
181        tbs: impl IntoIterator<Item = (u16, u8, Decimal, Decimal)>,
182    ) -> Self {
183        let mut all_balanced = true;
184        for (fy, fp, td, tc) in tbs {
185            let diff = td - tc;
186            let balanced = diff.is_zero();
187            all_balanced &= balanced;
188            self.tb_period_checks.push(TbPeriodCheck {
189                fiscal_year: fy,
190                fiscal_period: fp,
191                total_debit_balance: td.to_string(),
192                total_credit_balance: tc.to_string(),
193                difference: diff.to_string(),
194                balanced,
195            });
196        }
197        self.all_tbs_balanced = all_balanced;
198        self
199    }
200
201    /// True when the ledger is fully coherent: all JEs balance, the ledger nets
202    /// to zero, and every supplied trial balance balances.
203    pub fn is_coherent(&self) -> bool {
204        self.all_jes_balanced && self.ledger_nets_to_zero && self.all_tbs_balanced
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::models::journal_entry::{JournalEntry, JournalEntryHeader, JournalEntryLine};
212    use chrono::NaiveDate;
213
214    fn je(company: &str, lines: Vec<(&str, i64, i64)>) -> JournalEntry {
215        let mut e = JournalEntry::new(JournalEntryHeader::new(
216            company.to_string(),
217            NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
218        ));
219        for (i, (acct, dr, cr)) in lines.into_iter().enumerate() {
220            let ln = if dr != 0 {
221                JournalEntryLine::debit(
222                    e.header.document_id,
223                    (i + 1) as u32,
224                    acct.to_string(),
225                    Decimal::from(dr),
226                )
227            } else {
228                JournalEntryLine::credit(
229                    e.header.document_id,
230                    (i + 1) as u32,
231                    acct.to_string(),
232                    Decimal::from(cr),
233                )
234            };
235            e.add_line(ln);
236        }
237        e
238    }
239
240    #[test]
241    fn balanced_ledger_is_coherent() {
242        let entries = vec![
243            je("1000", vec![("4000", 1000, 0), ("1100", 0, 1000)]),
244            je("1000", vec![("5000", 500, 0), ("2000", 0, 500)]),
245        ];
246        let r = LedgerCoherenceReport::from_entries(&entries, 200);
247        assert_eq!(r.je_count, 2);
248        assert_eq!(r.line_count, 4);
249        assert_eq!(r.balanced_je_count, 2);
250        assert!(r.all_jes_balanced);
251        assert!(r.ledger_nets_to_zero);
252        assert_eq!(r.distinct_accounts, 4);
253        assert!(r.is_coherent());
254        assert!(r.unbalanced_jes.is_empty());
255    }
256
257    #[test]
258    fn unbalanced_je_is_flagged() {
259        // Manually construct an entry whose debits != credits.
260        let mut e = JournalEntry::new(JournalEntryHeader::new(
261            "1000".to_string(),
262            NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
263        ));
264        e.add_line(JournalEntryLine::debit(
265            e.header.document_id,
266            1,
267            "4000".into(),
268            Decimal::from(1000),
269        ));
270        e.add_line(JournalEntryLine::credit(
271            e.header.document_id,
272            2,
273            "1100".into(),
274            Decimal::from(995),
275        ));
276        let entries = vec![e];
277        let r = LedgerCoherenceReport::from_entries(&entries, 200);
278        assert!(!r.all_jes_balanced);
279        assert_eq!(r.unbalanced_je_count, 1);
280        assert_eq!(r.unbalanced_jes.len(), 1);
281        assert_eq!(r.unbalanced_jes[0].difference, "5");
282        assert!(!r.is_coherent());
283    }
284
285    #[test]
286    fn tb_checks_detect_imbalance() {
287        let entries = vec![je("1000", vec![("4000", 1000, 0), ("1100", 0, 1000)])];
288        let r = LedgerCoherenceReport::from_entries(&entries, 200).with_tb_checks([
289            (2026u16, 12u8, Decimal::from(5000), Decimal::from(5000)), // balanced
290            (2026u16, 11u8, Decimal::from(5000), Decimal::from(4990)), // imbalanced
291        ]);
292        assert_eq!(r.tb_period_checks.len(), 2);
293        assert!(!r.all_tbs_balanced);
294        assert!(!r.is_coherent());
295    }
296}