datasynth-core 5.35.2

Core domain models, traits, and distributions for synthetic enterprise data generation
Documentation
//! Ledger Coherence Report — a first-class integrity self-validation output.
//!
//! Proves the generated ledger is internally coherent: every journal entry
//! balances (debits = credits), the ledger as a whole nets to zero, and (when
//! trial balances are supplied by the runtime) each period's trial balance
//! balances. This doubles as a regression guard for engine-level coherence
//! issues (e.g. trial-balance imbalance in chain mode).
//!
//! The core (`from_entries`) is pure and depends only on journal entries; the
//! runtime augments the report with per-period trial-balance checks.

use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};

use super::JournalEntry;

/// A journal entry whose debits and credits do not balance.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UnbalancedJeRecord {
    pub document_id: String,
    pub company_code: String,
    /// Decimal serialized as a string (no IEEE-754 drift).
    pub total_debit: String,
    pub total_credit: String,
    pub difference: String,
}

/// Net activity for a single GL account across the ledger.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AccountActivity {
    pub gl_account: String,
    pub debit_total: String,
    pub credit_total: String,
    pub net: String,
    pub line_count: usize,
}

/// Per-period trial-balance self-balance check (populated by the runtime).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TbPeriodCheck {
    pub fiscal_year: u16,
    pub fiscal_period: u8,
    pub total_debit_balance: String,
    pub total_credit_balance: String,
    pub difference: String,
    pub balanced: bool,
}

/// Integrity self-validation report for a generated ledger.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LedgerCoherenceReport {
    pub je_count: usize,
    pub line_count: usize,
    pub balanced_je_count: usize,
    pub unbalanced_je_count: usize,
    /// Unbalanced entries (capped to keep the report bounded).
    pub unbalanced_jes: Vec<UnbalancedJeRecord>,
    pub all_jes_balanced: bool,
    pub total_debits: String,
    pub total_credits: String,
    /// True when total debits == total credits across the whole ledger.
    pub ledger_nets_to_zero: bool,
    pub distinct_accounts: usize,
    pub distinct_companies: usize,
    /// `[min_posting_date, max_posting_date]` (ISO) when any entries exist.
    pub period_span: Option<[String; 2]>,
    /// Top accounts by absolute net activity (capped).
    pub top_account_activity: Vec<AccountActivity>,
    /// Per-period trial-balance checks (empty until the runtime populates them).
    pub tb_period_checks: Vec<TbPeriodCheck>,
    /// True when every supplied period trial balance balances (vacuously true
    /// when no trial balances were supplied).
    pub all_tbs_balanced: bool,
}

impl LedgerCoherenceReport {
    /// Default cap on the number of unbalanced JEs / top accounts listed.
    pub const DEFAULT_LIST_CAP: usize = 200;

    /// Compute the coherence report from journal entries alone. The trial-balance
    /// section is left empty (the runtime augments it via [`Self::with_tb_checks`]).
    pub fn from_entries(entries: &[JournalEntry], list_cap: usize) -> Self {
        use std::collections::{BTreeMap, BTreeSet};

        let mut line_count = 0usize;
        let mut balanced = 0usize;
        let mut unbalanced: Vec<UnbalancedJeRecord> = Vec::new();
        let mut total_debits = Decimal::ZERO;
        let mut total_credits = Decimal::ZERO;
        let mut companies: BTreeSet<&str> = BTreeSet::new();
        // gl_account -> (debit, credit, line_count)
        let mut activity: BTreeMap<String, (Decimal, Decimal, usize)> = BTreeMap::new();
        let mut min_date = None;
        let mut max_date = None;

        for je in entries {
            line_count += je.lines.len();
            let td = je.total_debit();
            let tc = je.total_credit();
            total_debits += td;
            total_credits += tc;
            companies.insert(je.header.company_code.as_str());

            let d = je.header.posting_date;
            min_date = Some(min_date.map_or(d, |m: chrono::NaiveDate| m.min(d)));
            max_date = Some(max_date.map_or(d, |m: chrono::NaiveDate| m.max(d)));

            if je.is_balanced() {
                balanced += 1;
            } else if unbalanced.len() < list_cap {
                unbalanced.push(UnbalancedJeRecord {
                    document_id: je.header.document_id.to_string(),
                    company_code: je.header.company_code.clone(),
                    total_debit: td.to_string(),
                    total_credit: tc.to_string(),
                    difference: je.balance_difference().to_string(),
                });
            }

            for line in &je.lines {
                let e = activity.entry(line.gl_account.clone()).or_insert((
                    Decimal::ZERO,
                    Decimal::ZERO,
                    0,
                ));
                e.0 += line.debit_amount;
                e.1 += line.credit_amount;
                e.2 += 1;
            }
        }

        let unbalanced_je_count = entries.len() - balanced;
        let distinct_accounts = activity.len();

        // Top accounts by absolute net activity.
        let mut acts: Vec<AccountActivity> = activity
            .into_iter()
            .map(|(acct, (d, c, n))| AccountActivity {
                gl_account: acct,
                debit_total: d.to_string(),
                credit_total: c.to_string(),
                net: (d - c).to_string(),
                line_count: n,
            })
            .collect();
        acts.sort_by(|a, b| {
            let an = a.net.parse::<Decimal>().unwrap_or_default().abs();
            let bn = b.net.parse::<Decimal>().unwrap_or_default().abs();
            bn.cmp(&an)
        });
        acts.truncate(list_cap);

        let period_span = match (min_date, max_date) {
            (Some(a), Some(b)) => Some([a.to_string(), b.to_string()]),
            _ => None,
        };

        Self {
            je_count: entries.len(),
            line_count,
            balanced_je_count: balanced,
            unbalanced_je_count,
            unbalanced_jes: unbalanced,
            all_jes_balanced: unbalanced_je_count == 0,
            total_debits: total_debits.to_string(),
            total_credits: total_credits.to_string(),
            ledger_nets_to_zero: total_debits == total_credits,
            distinct_accounts,
            distinct_companies: companies.len(),
            period_span,
            top_account_activity: acts,
            tb_period_checks: Vec::new(),
            all_tbs_balanced: true,
        }
    }

    /// Attach per-period trial-balance self-balance checks. `tbs` is an iterator
    /// of `(fiscal_year, fiscal_period, total_debit_balance, total_credit_balance)`.
    pub fn with_tb_checks(
        mut self,
        tbs: impl IntoIterator<Item = (u16, u8, Decimal, Decimal)>,
    ) -> Self {
        let mut all_balanced = true;
        for (fy, fp, td, tc) in tbs {
            let diff = td - tc;
            let balanced = diff.is_zero();
            all_balanced &= balanced;
            self.tb_period_checks.push(TbPeriodCheck {
                fiscal_year: fy,
                fiscal_period: fp,
                total_debit_balance: td.to_string(),
                total_credit_balance: tc.to_string(),
                difference: diff.to_string(),
                balanced,
            });
        }
        self.all_tbs_balanced = all_balanced;
        self
    }

    /// True when the ledger is fully coherent: all JEs balance, the ledger nets
    /// to zero, and every supplied trial balance balances.
    pub fn is_coherent(&self) -> bool {
        self.all_jes_balanced && self.ledger_nets_to_zero && self.all_tbs_balanced
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::journal_entry::{JournalEntry, JournalEntryHeader, JournalEntryLine};
    use chrono::NaiveDate;

    fn je(company: &str, lines: Vec<(&str, i64, i64)>) -> JournalEntry {
        let mut e = JournalEntry::new(JournalEntryHeader::new(
            company.to_string(),
            NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
        ));
        for (i, (acct, dr, cr)) in lines.into_iter().enumerate() {
            let ln = if dr != 0 {
                JournalEntryLine::debit(
                    e.header.document_id,
                    (i + 1) as u32,
                    acct.to_string(),
                    Decimal::from(dr),
                )
            } else {
                JournalEntryLine::credit(
                    e.header.document_id,
                    (i + 1) as u32,
                    acct.to_string(),
                    Decimal::from(cr),
                )
            };
            e.add_line(ln);
        }
        e
    }

    #[test]
    fn balanced_ledger_is_coherent() {
        let entries = vec![
            je("1000", vec![("4000", 1000, 0), ("1100", 0, 1000)]),
            je("1000", vec![("5000", 500, 0), ("2000", 0, 500)]),
        ];
        let r = LedgerCoherenceReport::from_entries(&entries, 200);
        assert_eq!(r.je_count, 2);
        assert_eq!(r.line_count, 4);
        assert_eq!(r.balanced_je_count, 2);
        assert!(r.all_jes_balanced);
        assert!(r.ledger_nets_to_zero);
        assert_eq!(r.distinct_accounts, 4);
        assert!(r.is_coherent());
        assert!(r.unbalanced_jes.is_empty());
    }

    #[test]
    fn unbalanced_je_is_flagged() {
        // Manually construct an entry whose debits != credits.
        let mut e = JournalEntry::new(JournalEntryHeader::new(
            "1000".to_string(),
            NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
        ));
        e.add_line(JournalEntryLine::debit(
            e.header.document_id,
            1,
            "4000".into(),
            Decimal::from(1000),
        ));
        e.add_line(JournalEntryLine::credit(
            e.header.document_id,
            2,
            "1100".into(),
            Decimal::from(995),
        ));
        let entries = vec![e];
        let r = LedgerCoherenceReport::from_entries(&entries, 200);
        assert!(!r.all_jes_balanced);
        assert_eq!(r.unbalanced_je_count, 1);
        assert_eq!(r.unbalanced_jes.len(), 1);
        assert_eq!(r.unbalanced_jes[0].difference, "5");
        assert!(!r.is_coherent());
    }

    #[test]
    fn tb_checks_detect_imbalance() {
        let entries = vec![je("1000", vec![("4000", 1000, 0), ("1100", 0, 1000)])];
        let r = LedgerCoherenceReport::from_entries(&entries, 200).with_tb_checks([
            (2026u16, 12u8, Decimal::from(5000), Decimal::from(5000)), // balanced
            (2026u16, 11u8, Decimal::from(5000), Decimal::from(4990)), // imbalanced
        ]);
        assert_eq!(r.tb_period_checks.len(), 2);
        assert!(!r.all_tbs_balanced);
        assert!(!r.is_coherent());
    }
}