datasynth_generators/balance/
balance_tracker.rs

1//! Running balance tracker.
2//!
3//! Maintains real-time account balances as journal entries are processed,
4//! with continuous validation of balance sheet integrity.
5
6use chrono::{Datelike, NaiveDate};
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use std::collections::HashMap;
10
11use datasynth_core::models::balance::{
12    AccountBalance, AccountPeriodActivity, AccountType, BalanceSnapshot,
13};
14use datasynth_core::models::{JournalEntry, JournalEntryLine};
15
16/// Configuration for the balance tracker.
17#[derive(Debug, Clone)]
18pub struct BalanceTrackerConfig {
19    /// Whether to validate balance sheet equation after each entry.
20    pub validate_on_each_entry: bool,
21    /// Whether to track balance history.
22    pub track_history: bool,
23    /// Tolerance for balance sheet validation (for rounding).
24    pub balance_tolerance: Decimal,
25    /// Whether to fail on validation errors.
26    pub fail_on_validation_error: bool,
27}
28
29impl Default for BalanceTrackerConfig {
30    fn default() -> Self {
31        Self {
32            validate_on_each_entry: true,
33            track_history: true,
34            balance_tolerance: dec!(0.01),
35            fail_on_validation_error: false,
36        }
37    }
38}
39
40/// Tracks running balances for all accounts across companies.
41pub struct RunningBalanceTracker {
42    config: BalanceTrackerConfig,
43    /// Balances by company code -> account code -> balance.
44    balances: HashMap<String, HashMap<String, AccountBalance>>,
45    /// Account type registry for determining debit/credit behavior.
46    account_types: HashMap<String, AccountType>,
47    /// Balance history by company code.
48    history: HashMap<String, Vec<BalanceHistoryEntry>>,
49    /// Validation errors encountered.
50    validation_errors: Vec<ValidationError>,
51    /// Statistics.
52    stats: TrackerStatistics,
53}
54
55/// Entry in balance history.
56#[derive(Debug, Clone)]
57pub struct BalanceHistoryEntry {
58    pub date: NaiveDate,
59    pub entry_id: String,
60    pub account_code: String,
61    pub previous_balance: Decimal,
62    pub change: Decimal,
63    pub new_balance: Decimal,
64}
65
66/// Validation error details.
67#[derive(Debug, Clone)]
68pub struct ValidationError {
69    pub date: NaiveDate,
70    pub company_code: String,
71    pub entry_id: Option<String>,
72    pub error_type: ValidationErrorType,
73    pub message: String,
74    pub details: HashMap<String, Decimal>,
75}
76
77/// Types of validation errors.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum ValidationErrorType {
80    /// Entry debits don't equal credits.
81    UnbalancedEntry,
82    /// Balance sheet equation violated.
83    BalanceSheetImbalance,
84    /// Account has negative balance where not allowed.
85    NegativeBalance,
86    /// Unknown account code.
87    UnknownAccount,
88    /// Entry applied out of chronological order.
89    OutOfOrder,
90}
91
92/// Statistics about tracked entries.
93#[derive(Debug, Clone, Default)]
94pub struct TrackerStatistics {
95    pub entries_processed: u64,
96    pub lines_processed: u64,
97    pub total_debits: Decimal,
98    pub total_credits: Decimal,
99    pub companies_tracked: usize,
100    pub accounts_tracked: usize,
101    pub validation_errors: usize,
102}
103
104impl RunningBalanceTracker {
105    /// Creates a new balance tracker.
106    pub fn new(config: BalanceTrackerConfig) -> Self {
107        Self {
108            config,
109            balances: HashMap::new(),
110            account_types: HashMap::new(),
111            history: HashMap::new(),
112            validation_errors: Vec::new(),
113            stats: TrackerStatistics::default(),
114        }
115    }
116
117    /// Creates a tracker with default configuration.
118    pub fn with_defaults() -> Self {
119        Self::new(BalanceTrackerConfig::default())
120    }
121
122    /// Registers an account type for balance tracking.
123    pub fn register_account_type(&mut self, account_code: &str, account_type: AccountType) {
124        self.account_types
125            .insert(account_code.to_string(), account_type);
126    }
127
128    /// Registers multiple account types.
129    pub fn register_account_types(&mut self, types: &[(String, AccountType)]) {
130        for (code, account_type) in types {
131            self.account_types.insert(code.clone(), *account_type);
132        }
133    }
134
135    /// Registers account types from a chart of accounts prefix pattern.
136    pub fn register_from_chart_prefixes(&mut self, prefixes: &[(&str, AccountType)]) {
137        for (prefix, account_type) in prefixes {
138            self.account_types.insert(prefix.to_string(), *account_type);
139        }
140    }
141
142    /// Initializes balances from opening balance snapshot.
143    pub fn initialize_from_snapshot(&mut self, snapshot: &BalanceSnapshot) {
144        let company_balances = self
145            .balances
146            .entry(snapshot.company_code.clone())
147            .or_default();
148
149        for (account_code, balance) in &snapshot.balances {
150            company_balances.insert(account_code.clone(), balance.clone());
151        }
152
153        self.stats.companies_tracked = self.balances.len();
154        self.stats.accounts_tracked = self.balances.values().map(|b| b.len()).sum();
155    }
156
157    /// Applies a journal entry to the running balances.
158    pub fn apply_entry(&mut self, entry: &JournalEntry) -> Result<(), ValidationError> {
159        // Validate entry is balanced first
160        if !entry.is_balanced() {
161            let error = ValidationError {
162                date: entry.posting_date(),
163                company_code: entry.company_code().to_string(),
164                entry_id: Some(entry.document_number().clone()),
165                error_type: ValidationErrorType::UnbalancedEntry,
166                message: format!(
167                    "Entry {} is unbalanced: debits={}, credits={}",
168                    entry.document_number(),
169                    entry.total_debit(),
170                    entry.total_credit()
171                ),
172                details: {
173                    let mut d = HashMap::new();
174                    d.insert("total_debit".to_string(), entry.total_debit());
175                    d.insert("total_credit".to_string(), entry.total_credit());
176                    d
177                },
178            };
179
180            if self.config.fail_on_validation_error {
181                return Err(error);
182            }
183            self.validation_errors.push(error);
184        }
185
186        // Extract data we need before mutably borrowing balances
187        let company_code = entry.company_code().to_string();
188        let document_number = entry.document_number().clone();
189        let posting_date = entry.posting_date();
190        let track_history = self.config.track_history;
191
192        // Pre-compute account types for all lines
193        let line_data: Vec<_> = entry
194            .lines
195            .iter()
196            .map(|line| {
197                let account_type = self.determine_account_type(&line.account_code);
198                (line.clone(), account_type)
199            })
200            .collect();
201
202        // Get or create company balances
203        let company_balances = self.balances.entry(company_code.clone()).or_default();
204
205        // History entries to add
206        let mut history_entries = Vec::new();
207
208        // Apply each line
209        for (line, account_type) in &line_data {
210            // Get or create account balance
211            let balance = company_balances
212                .entry(line.account_code.clone())
213                .or_insert_with(|| {
214                    AccountBalance::new(
215                        company_code.clone(),
216                        line.account_code.clone(),
217                        *account_type,
218                        "USD".to_string(),
219                        posting_date.year(),
220                        posting_date.month(),
221                    )
222                });
223
224            let previous_balance = balance.closing_balance;
225
226            // Apply debit or credit
227            if line.debit_amount > Decimal::ZERO {
228                balance.apply_debit(line.debit_amount);
229            }
230            if line.credit_amount > Decimal::ZERO {
231                balance.apply_credit(line.credit_amount);
232            }
233
234            let new_balance = balance.closing_balance;
235
236            // Record history if configured
237            if track_history {
238                let change = line.debit_amount - line.credit_amount;
239                history_entries.push(BalanceHistoryEntry {
240                    date: posting_date,
241                    entry_id: document_number.clone(),
242                    account_code: line.account_code.clone(),
243                    previous_balance,
244                    change,
245                    new_balance,
246                });
247            }
248        }
249
250        // Add history entries after releasing the balances borrow
251        if !history_entries.is_empty() {
252            let hist = self.history.entry(company_code.clone()).or_default();
253            hist.extend(history_entries);
254        }
255
256        // Update statistics
257        self.stats.entries_processed += 1;
258        self.stats.lines_processed += entry.lines.len() as u64;
259        self.stats.total_debits += entry.total_debit();
260        self.stats.total_credits += entry.total_credit();
261        self.stats.companies_tracked = self.balances.len();
262        self.stats.accounts_tracked = self.balances.values().map(|b| b.len()).sum();
263
264        // Validate balance sheet if configured
265        if self.config.validate_on_each_entry {
266            self.validate_balance_sheet(
267                entry.company_code(),
268                entry.posting_date(),
269                Some(&entry.document_number()),
270            )?;
271        }
272
273        Ok(())
274    }
275
276    /// Applies a batch of entries.
277    pub fn apply_entries(&mut self, entries: &[JournalEntry]) -> Vec<ValidationError> {
278        let mut errors = Vec::new();
279
280        for entry in entries {
281            if let Err(error) = self.apply_entry(entry) {
282                errors.push(error);
283            }
284        }
285
286        errors
287    }
288
289    /// Applies a single journal entry line.
290    fn apply_line(
291        &mut self,
292        company_balances: &mut HashMap<String, AccountBalance>,
293        line: &JournalEntryLine,
294        entry_id: &str,
295        date: NaiveDate,
296        company_code: &str,
297    ) {
298        let account_type = self.determine_account_type(&line.account_code);
299
300        // Get or create account balance
301        let balance = company_balances
302            .entry(line.account_code.clone())
303            .or_insert_with(|| {
304                AccountBalance::new(
305                    company_code.to_string(),
306                    line.account_code.clone(),
307                    account_type,
308                    "USD".to_string(), // Default currency
309                    date.year(),
310                    date.month(),
311                )
312            });
313
314        let previous_balance = balance.closing_balance;
315
316        // Apply debit or credit based on account type
317        if line.debit_amount > Decimal::ZERO {
318            balance.apply_debit(line.debit_amount);
319        }
320        if line.credit_amount > Decimal::ZERO {
321            balance.apply_credit(line.credit_amount);
322        }
323
324        let new_balance = balance.closing_balance;
325
326        // Record history if configured
327        if self.config.track_history {
328            let history_entries = self.history.entry(company_code.to_string()).or_default();
329            history_entries.push(BalanceHistoryEntry {
330                date,
331                entry_id: entry_id.to_string(),
332                account_code: line.account_code.clone(),
333                previous_balance,
334                change: new_balance - previous_balance,
335                new_balance,
336            });
337        }
338    }
339
340    /// Determines account type from code prefix.
341    fn determine_account_type(&self, account_code: &str) -> AccountType {
342        // Check registered types first (exact match or prefix)
343        for (registered_code, account_type) in &self.account_types {
344            if account_code.starts_with(registered_code) {
345                return *account_type;
346            }
347        }
348
349        // Default logic based on first digit
350        match account_code.chars().next() {
351            Some('1') => AccountType::Asset,
352            Some('2') => AccountType::Liability,
353            Some('3') => AccountType::Equity,
354            Some('4') => AccountType::Revenue,
355            Some('5') | Some('6') | Some('7') | Some('8') => AccountType::Expense,
356            _ => AccountType::Asset, // Default fallback
357        }
358    }
359
360    /// Validates the balance sheet equation for a company.
361    pub fn validate_balance_sheet(
362        &mut self,
363        company_code: &str,
364        date: NaiveDate,
365        entry_id: Option<&str>,
366    ) -> Result<(), ValidationError> {
367        let Some(company_balances) = self.balances.get(company_code) else {
368            return Ok(()); // No balances to validate
369        };
370
371        let mut total_assets = Decimal::ZERO;
372        let mut total_liabilities = Decimal::ZERO;
373        let mut total_equity = Decimal::ZERO;
374        let mut total_revenue = Decimal::ZERO;
375        let mut total_expenses = Decimal::ZERO;
376
377        for (account_code, balance) in company_balances {
378            let account_type = self.determine_account_type(account_code);
379            match account_type {
380                AccountType::Asset => total_assets += balance.closing_balance,
381                AccountType::ContraAsset => total_assets -= balance.closing_balance.abs(),
382                AccountType::Liability => total_liabilities += balance.closing_balance.abs(),
383                AccountType::ContraLiability => total_liabilities -= balance.closing_balance.abs(),
384                AccountType::Equity => total_equity += balance.closing_balance.abs(),
385                AccountType::ContraEquity => total_equity -= balance.closing_balance.abs(),
386                AccountType::Revenue => total_revenue += balance.closing_balance.abs(),
387                AccountType::Expense => total_expenses += balance.closing_balance.abs(),
388            }
389        }
390
391        // Net income = Revenue - Expenses
392        let net_income = total_revenue - total_expenses;
393
394        // Balance sheet equation: Assets = Liabilities + Equity + Net Income
395        let left_side = total_assets;
396        let right_side = total_liabilities + total_equity + net_income;
397        let difference = (left_side - right_side).abs();
398
399        if difference > self.config.balance_tolerance {
400            let error = ValidationError {
401                date,
402                company_code: company_code.to_string(),
403                entry_id: entry_id.map(String::from),
404                error_type: ValidationErrorType::BalanceSheetImbalance,
405                message: format!(
406                    "Balance sheet imbalance: Assets ({}) != L + E + NI ({}), diff = {}",
407                    left_side, right_side, difference
408                ),
409                details: {
410                    let mut d = HashMap::new();
411                    d.insert("total_assets".to_string(), total_assets);
412                    d.insert("total_liabilities".to_string(), total_liabilities);
413                    d.insert("total_equity".to_string(), total_equity);
414                    d.insert("net_income".to_string(), net_income);
415                    d.insert("difference".to_string(), difference);
416                    d
417                },
418            };
419
420            self.stats.validation_errors += 1;
421
422            if self.config.fail_on_validation_error {
423                return Err(error);
424            }
425            self.validation_errors.push(error);
426        }
427
428        Ok(())
429    }
430
431    /// Gets the current snapshot for a company.
432    pub fn get_snapshot(
433        &self,
434        company_code: &str,
435        as_of_date: NaiveDate,
436    ) -> Option<BalanceSnapshot> {
437        use chrono::Datelike;
438        self.balances.get(company_code).map(|balances| {
439            let mut snapshot = BalanceSnapshot::new(
440                format!("SNAP-{}-{}", company_code, as_of_date),
441                company_code.to_string(),
442                as_of_date,
443                as_of_date.year(),
444                as_of_date.month(),
445                "USD".to_string(),
446            );
447            for (account, balance) in balances {
448                snapshot.balances.insert(account.clone(), balance.clone());
449            }
450            snapshot.recalculate_totals();
451            snapshot
452        })
453    }
454
455    /// Gets snapshots for all companies.
456    pub fn get_all_snapshots(&self, as_of_date: NaiveDate) -> Vec<BalanceSnapshot> {
457        use chrono::Datelike;
458        self.balances
459            .iter()
460            .map(|(company_code, balances)| {
461                let mut snapshot = BalanceSnapshot::new(
462                    format!("SNAP-{}-{}", company_code, as_of_date),
463                    company_code.clone(),
464                    as_of_date,
465                    as_of_date.year(),
466                    as_of_date.month(),
467                    "USD".to_string(),
468                );
469                for (account, balance) in balances {
470                    snapshot.balances.insert(account.clone(), balance.clone());
471                }
472                snapshot.recalculate_totals();
473                snapshot
474            })
475            .collect()
476    }
477
478    /// Gets balance changes for a period.
479    pub fn get_balance_changes(
480        &self,
481        company_code: &str,
482        from_date: NaiveDate,
483        to_date: NaiveDate,
484    ) -> Vec<AccountPeriodActivity> {
485        let Some(history) = self.history.get(company_code) else {
486            return Vec::new();
487        };
488
489        let mut changes_by_account: HashMap<String, AccountPeriodActivity> = HashMap::new();
490
491        for entry in history
492            .iter()
493            .filter(|e| e.date >= from_date && e.date <= to_date)
494        {
495            let change = changes_by_account
496                .entry(entry.account_code.clone())
497                .or_insert_with(|| AccountPeriodActivity {
498                    account_code: entry.account_code.clone(),
499                    period_start: from_date,
500                    period_end: to_date,
501                    opening_balance: Decimal::ZERO,
502                    closing_balance: Decimal::ZERO,
503                    total_debits: Decimal::ZERO,
504                    total_credits: Decimal::ZERO,
505                    net_change: Decimal::ZERO,
506                    transaction_count: 0,
507                });
508
509            if entry.change > Decimal::ZERO {
510                change.total_debits += entry.change;
511            } else {
512                change.total_credits += entry.change.abs();
513            }
514            change.net_change += entry.change;
515            change.transaction_count += 1;
516        }
517
518        // Update opening/closing balances
519        if let Some(company_balances) = self.balances.get(company_code) {
520            for change in changes_by_account.values_mut() {
521                if let Some(balance) = company_balances.get(&change.account_code) {
522                    change.closing_balance = balance.closing_balance;
523                    change.opening_balance = change.closing_balance - change.net_change;
524                }
525            }
526        }
527
528        changes_by_account.into_values().collect()
529    }
530
531    /// Gets balance for a specific account.
532    pub fn get_account_balance(
533        &self,
534        company_code: &str,
535        account_code: &str,
536    ) -> Option<&AccountBalance> {
537        self.balances
538            .get(company_code)
539            .and_then(|b| b.get(account_code))
540    }
541
542    /// Gets all validation errors.
543    pub fn get_validation_errors(&self) -> &[ValidationError] {
544        &self.validation_errors
545    }
546
547    /// Clears validation errors.
548    pub fn clear_validation_errors(&mut self) {
549        self.validation_errors.clear();
550        self.stats.validation_errors = 0;
551    }
552
553    /// Gets tracker statistics.
554    pub fn get_statistics(&self) -> &TrackerStatistics {
555        &self.stats
556    }
557
558    /// Rolls forward balances to a new period.
559    pub fn roll_forward(&mut self, _new_period_start: NaiveDate) {
560        for company_balances in self.balances.values_mut() {
561            for balance in company_balances.values_mut() {
562                balance.roll_forward();
563            }
564        }
565    }
566
567    /// Exports balances to a simple format.
568    pub fn export_balances(&self, company_code: &str) -> Vec<(String, Decimal)> {
569        self.balances
570            .get(company_code)
571            .map(|balances| {
572                balances
573                    .iter()
574                    .map(|(code, balance)| (code.clone(), balance.closing_balance))
575                    .collect()
576            })
577            .unwrap_or_default()
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use datasynth_core::models::JournalEntry;
585
586    fn create_test_entry(
587        company: &str,
588        account1: &str,
589        account2: &str,
590        amount: Decimal,
591    ) -> JournalEntry {
592        let mut entry = JournalEntry::new_simple(
593            "TEST001".to_string(),
594            company.to_string(),
595            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
596            "Test entry".to_string(),
597        );
598
599        entry.add_line(JournalEntryLine {
600            line_number: 1,
601            gl_account: account1.to_string(),
602            account_code: account1.to_string(),
603            debit_amount: amount,
604            ..Default::default()
605        });
606
607        entry.add_line(JournalEntryLine {
608            line_number: 2,
609            gl_account: account2.to_string(),
610            account_code: account2.to_string(),
611            credit_amount: amount,
612            ..Default::default()
613        });
614
615        entry
616    }
617
618    #[test]
619    fn test_apply_balanced_entry() {
620        let mut tracker = RunningBalanceTracker::with_defaults();
621        tracker.register_account_type("1100", AccountType::Asset);
622        tracker.register_account_type("4000", AccountType::Revenue);
623
624        let entry = create_test_entry("1000", "1100", "4000", dec!(1000));
625        let result = tracker.apply_entry(&entry);
626
627        assert!(result.is_ok());
628        assert_eq!(tracker.stats.entries_processed, 1);
629        assert_eq!(tracker.stats.lines_processed, 2);
630    }
631
632    #[test]
633    fn test_balance_accumulation() {
634        let mut tracker = RunningBalanceTracker::with_defaults();
635        tracker.config.validate_on_each_entry = false;
636
637        let entry1 = create_test_entry("1000", "1100", "4000", dec!(1000));
638        let entry2 = create_test_entry("1000", "1100", "4000", dec!(500));
639
640        tracker.apply_entry(&entry1).unwrap();
641        tracker.apply_entry(&entry2).unwrap();
642
643        let balance = tracker.get_account_balance("1000", "1100").unwrap();
644        assert_eq!(balance.closing_balance, dec!(1500));
645    }
646
647    #[test]
648    fn test_get_snapshot() {
649        let mut tracker = RunningBalanceTracker::with_defaults();
650        tracker.config.validate_on_each_entry = false;
651
652        let entry = create_test_entry("1000", "1100", "2000", dec!(1000));
653        tracker.apply_entry(&entry).unwrap();
654
655        let snapshot = tracker
656            .get_snapshot("1000", NaiveDate::from_ymd_opt(2024, 1, 31).unwrap())
657            .unwrap();
658
659        assert_eq!(snapshot.balances.len(), 2);
660    }
661
662    #[test]
663    fn test_determine_account_type_from_prefix() {
664        let tracker = RunningBalanceTracker::with_defaults();
665
666        assert_eq!(tracker.determine_account_type("1000"), AccountType::Asset);
667        assert_eq!(
668            tracker.determine_account_type("2000"),
669            AccountType::Liability
670        );
671        assert_eq!(tracker.determine_account_type("3000"), AccountType::Equity);
672        assert_eq!(tracker.determine_account_type("4000"), AccountType::Revenue);
673        assert_eq!(tracker.determine_account_type("5000"), AccountType::Expense);
674    }
675}