Skip to main content

datasynth_generators/balance/
trial_balance_generator.rs

1//! Trial balance generator.
2//!
3//! Generates trial balances at period end from running balance snapshots,
4//! with support for:
5//! - Unadjusted, adjusted, and post-closing trial balances
6//! - Category summaries and subtotals
7//! - Comparative trial balances across periods
8//! - Consolidated trial balances across companies
9
10use chrono::NaiveDate;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use std::collections::HashMap;
14use tracing::debug;
15
16use datasynth_core::models::balance::{
17    AccountBalance, AccountCategory, AccountType, BalanceSnapshot, CategorySummary,
18    ComparativeTrialBalance, TrialBalance, TrialBalanceLine, TrialBalanceStatus, TrialBalanceType,
19};
20use datasynth_core::models::ChartOfAccounts;
21
22use super::RunningBalanceTracker;
23
24/// Configuration for trial balance generation.
25#[derive(Debug, Clone)]
26pub struct TrialBalanceConfig {
27    /// Include zero balance accounts.
28    pub include_zero_balances: bool,
29    /// Group accounts by category.
30    pub group_by_category: bool,
31    /// Generate category subtotals.
32    pub generate_subtotals: bool,
33    /// Sort accounts by code.
34    pub sort_by_account_code: bool,
35    /// Trial balance type to generate.
36    pub trial_balance_type: TrialBalanceType,
37}
38
39impl Default for TrialBalanceConfig {
40    fn default() -> Self {
41        Self {
42            include_zero_balances: false,
43            group_by_category: true,
44            generate_subtotals: true,
45            sort_by_account_code: true,
46            trial_balance_type: TrialBalanceType::Unadjusted,
47        }
48    }
49}
50
51/// Generator for trial balance reports.
52pub struct TrialBalanceGenerator {
53    config: TrialBalanceConfig,
54    /// Account category mappings.
55    category_mappings: HashMap<String, AccountCategory>,
56    /// Account descriptions.
57    account_descriptions: HashMap<String, String>,
58}
59
60impl TrialBalanceGenerator {
61    /// Creates a new trial balance generator.
62    pub fn new(config: TrialBalanceConfig) -> Self {
63        Self {
64            config,
65            category_mappings: HashMap::new(),
66            account_descriptions: HashMap::new(),
67        }
68    }
69
70    /// Creates a generator with default configuration.
71    pub fn with_defaults() -> Self {
72        Self::new(TrialBalanceConfig::default())
73    }
74
75    /// Registers category mappings from chart of accounts.
76    pub fn register_from_chart(&mut self, chart: &ChartOfAccounts) {
77        for account in &chart.accounts {
78            self.account_descriptions.insert(
79                account.account_code().to_string(),
80                account.description().to_string(),
81            );
82
83            // Determine category from account code prefix
84            let category = self.determine_category(account.account_code());
85            self.category_mappings
86                .insert(account.account_code().to_string(), category);
87        }
88    }
89
90    /// Registers a custom category mapping.
91    pub fn register_category(&mut self, account_code: &str, category: AccountCategory) {
92        self.category_mappings
93            .insert(account_code.to_string(), category);
94    }
95
96    /// Generates a trial balance from a balance snapshot.
97    pub fn generate_from_snapshot(
98        &self,
99        snapshot: &BalanceSnapshot,
100        fiscal_year: i32,
101        fiscal_period: u32,
102    ) -> TrialBalance {
103        debug!(
104            company_code = %snapshot.company_code,
105            fiscal_year,
106            fiscal_period,
107            balance_count = snapshot.balances.len(),
108            "Generating trial balance from snapshot"
109        );
110
111        let mut lines = Vec::new();
112        let mut total_debits = Decimal::ZERO;
113        let mut total_credits = Decimal::ZERO;
114
115        // Convert balances to trial balance lines
116        for (account_code, balance) in &snapshot.balances {
117            if !self.config.include_zero_balances && balance.closing_balance == Decimal::ZERO {
118                continue;
119            }
120
121            let (debit, credit) = self.split_balance(balance);
122            total_debits += debit;
123            total_credits += credit;
124
125            let category = self.determine_category(account_code);
126            let description = self
127                .account_descriptions
128                .get(account_code)
129                .cloned()
130                .unwrap_or_else(|| format!("Account {}", account_code));
131
132            lines.push(TrialBalanceLine {
133                account_code: account_code.clone(),
134                account_description: description,
135                category,
136                account_type: balance.account_type,
137                debit_balance: debit,
138                credit_balance: credit,
139                opening_balance: balance.opening_balance,
140                period_debits: balance.period_debits,
141                period_credits: balance.period_credits,
142                closing_balance: balance.closing_balance,
143                cost_center: None,
144                profit_center: None,
145            });
146        }
147
148        // Sort lines
149        if self.config.sort_by_account_code {
150            lines.sort_by(|a, b| a.account_code.cmp(&b.account_code));
151        }
152
153        // Calculate category summaries
154        let category_summary = if self.config.group_by_category {
155            self.calculate_category_summary(&lines)
156        } else {
157            HashMap::new()
158        };
159
160        let out_of_balance = total_debits - total_credits;
161
162        let mut tb = TrialBalance {
163            trial_balance_id: format!(
164                "TB-{}-{}-{:02}",
165                snapshot.company_code, fiscal_year, fiscal_period
166            ),
167            company_code: snapshot.company_code.clone(),
168            company_name: None,
169            as_of_date: snapshot.as_of_date,
170            fiscal_year,
171            fiscal_period,
172            currency: snapshot.currency.clone(),
173            balance_type: self.config.trial_balance_type,
174            lines,
175            total_debits,
176            total_credits,
177            is_balanced: out_of_balance.abs() < dec!(0.01),
178            out_of_balance,
179            is_equation_valid: false,           // Will be calculated below
180            equation_difference: Decimal::ZERO, // Will be calculated below
181            category_summary,
182            created_at: snapshot
183                .as_of_date
184                .and_hms_opt(23, 59, 59)
185                .unwrap_or_default(),
186            created_by: "TrialBalanceGenerator".to_string(),
187            approved_by: None,
188            approved_at: None,
189            status: TrialBalanceStatus::Draft,
190        };
191
192        // Calculate and set accounting equation validity
193        let (is_valid, _assets, _liabilities, _equity, diff) = tb.validate_accounting_equation();
194        tb.is_equation_valid = is_valid;
195        tb.equation_difference = diff;
196
197        tb
198    }
199
200    /// Generates a trial balance from the balance tracker.
201    pub fn generate_from_tracker(
202        &self,
203        tracker: &RunningBalanceTracker,
204        company_code: &str,
205        as_of_date: NaiveDate,
206        fiscal_year: i32,
207        fiscal_period: u32,
208    ) -> Option<TrialBalance> {
209        tracker
210            .get_snapshot(company_code, as_of_date)
211            .map(|snapshot| self.generate_from_snapshot(&snapshot, fiscal_year, fiscal_period))
212    }
213
214    /// Generates trial balances for all companies in the tracker.
215    pub fn generate_all_from_tracker(
216        &self,
217        tracker: &RunningBalanceTracker,
218        as_of_date: NaiveDate,
219        fiscal_year: i32,
220        fiscal_period: u32,
221    ) -> Vec<TrialBalance> {
222        tracker
223            .get_all_snapshots(as_of_date)
224            .iter()
225            .map(|snapshot| self.generate_from_snapshot(snapshot, fiscal_year, fiscal_period))
226            .collect()
227    }
228
229    /// Generates a comparative trial balance across multiple periods.
230    pub fn generate_comparative(
231        &self,
232        snapshots: &[(NaiveDate, BalanceSnapshot)],
233        fiscal_year: i32,
234    ) -> ComparativeTrialBalance {
235        use datasynth_core::models::balance::ComparativeTrialBalanceLine;
236
237        // Generate trial balances for each period
238        let trial_balances: Vec<TrialBalance> = snapshots
239            .iter()
240            .enumerate()
241            .map(|(i, (date, snapshot))| {
242                let mut tb = self.generate_from_snapshot(snapshot, fiscal_year, (i + 1) as u32);
243                tb.as_of_date = *date;
244                tb
245            })
246            .collect();
247
248        // Build periods list
249        let periods: Vec<(i32, u32)> = trial_balances
250            .iter()
251            .map(|tb| (tb.fiscal_year, tb.fiscal_period))
252            .collect();
253
254        // Build comparative lines
255        let mut lines_map: HashMap<String, ComparativeTrialBalanceLine> = HashMap::new();
256
257        for tb in &trial_balances {
258            for line in &tb.lines {
259                let entry = lines_map
260                    .entry(line.account_code.clone())
261                    .or_insert_with(|| ComparativeTrialBalanceLine {
262                        account_code: line.account_code.clone(),
263                        account_description: line.account_description.clone(),
264                        category: line.category,
265                        period_balances: HashMap::new(),
266                        period_changes: HashMap::new(),
267                    });
268
269                entry
270                    .period_balances
271                    .insert((tb.fiscal_year, tb.fiscal_period), line.closing_balance);
272            }
273        }
274
275        // Calculate period-over-period changes
276        for line in lines_map.values_mut() {
277            let mut sorted_periods: Vec<_> = line.period_balances.keys().cloned().collect();
278            sorted_periods.sort();
279
280            for i in 1..sorted_periods.len() {
281                let prev_period = sorted_periods[i - 1];
282                let curr_period = sorted_periods[i];
283
284                if let (Some(&prev_balance), Some(&curr_balance)) = (
285                    line.period_balances.get(&prev_period),
286                    line.period_balances.get(&curr_period),
287                ) {
288                    line.period_changes
289                        .insert(curr_period, curr_balance - prev_balance);
290                }
291            }
292        }
293
294        let lines: Vec<ComparativeTrialBalanceLine> = lines_map.into_values().collect();
295
296        let company_code = snapshots
297            .first()
298            .map(|(_, s)| s.company_code.clone())
299            .unwrap_or_default();
300
301        let currency = snapshots
302            .first()
303            .map(|(_, s)| s.currency.clone())
304            .unwrap_or_else(|| "USD".to_string());
305
306        let created_at = snapshots
307            .last()
308            .map(|(date, _)| date.and_hms_opt(23, 59, 59).unwrap_or_default())
309            .unwrap_or_default();
310
311        ComparativeTrialBalance {
312            company_code,
313            currency,
314            periods,
315            lines,
316            created_at,
317        }
318    }
319
320    /// Generates a consolidated trial balance across companies.
321    pub fn generate_consolidated(
322        &self,
323        trial_balances: &[TrialBalance],
324        consolidated_company_code: &str,
325    ) -> TrialBalance {
326        let mut consolidated_balances: HashMap<String, TrialBalanceLine> = HashMap::new();
327
328        for tb in trial_balances {
329            for line in &tb.lines {
330                let entry = consolidated_balances
331                    .entry(line.account_code.clone())
332                    .or_insert_with(|| TrialBalanceLine {
333                        account_code: line.account_code.clone(),
334                        account_description: line.account_description.clone(),
335                        category: line.category,
336                        account_type: line.account_type,
337                        debit_balance: Decimal::ZERO,
338                        credit_balance: Decimal::ZERO,
339                        opening_balance: Decimal::ZERO,
340                        period_debits: Decimal::ZERO,
341                        period_credits: Decimal::ZERO,
342                        closing_balance: Decimal::ZERO,
343                        cost_center: None,
344                        profit_center: None,
345                    });
346
347                entry.debit_balance += line.debit_balance;
348                entry.credit_balance += line.credit_balance;
349                entry.opening_balance += line.opening_balance;
350                entry.period_debits += line.period_debits;
351                entry.period_credits += line.period_credits;
352                entry.closing_balance += line.closing_balance;
353            }
354        }
355
356        let mut lines: Vec<TrialBalanceLine> = consolidated_balances.into_values().collect();
357        if self.config.sort_by_account_code {
358            lines.sort_by(|a, b| a.account_code.cmp(&b.account_code));
359        }
360
361        let total_debits: Decimal = lines.iter().map(|l| l.debit_balance).sum();
362        let total_credits: Decimal = lines.iter().map(|l| l.credit_balance).sum();
363
364        let category_summary = if self.config.group_by_category {
365            self.calculate_category_summary(&lines)
366        } else {
367            HashMap::new()
368        };
369
370        let as_of_date = trial_balances
371            .first()
372            .map(|tb| tb.as_of_date)
373            .unwrap_or_else(|| chrono::Local::now().date_naive());
374
375        let fiscal_year = trial_balances.first().map(|tb| tb.fiscal_year).unwrap_or(0);
376        let fiscal_period = trial_balances
377            .first()
378            .map(|tb| tb.fiscal_period)
379            .unwrap_or(0);
380
381        let currency = trial_balances
382            .first()
383            .map(|tb| tb.currency.clone())
384            .unwrap_or_else(|| "USD".to_string());
385
386        let out_of_balance = total_debits - total_credits;
387
388        let mut tb = TrialBalance {
389            trial_balance_id: format!(
390                "TB-CONS-{}-{}-{:02}",
391                consolidated_company_code, fiscal_year, fiscal_period
392            ),
393            company_code: consolidated_company_code.to_string(),
394            company_name: None,
395            as_of_date,
396            fiscal_year,
397            fiscal_period,
398            currency,
399            balance_type: TrialBalanceType::Consolidated,
400            lines,
401            total_debits,
402            total_credits,
403            is_balanced: out_of_balance.abs() < dec!(0.01),
404            out_of_balance,
405            is_equation_valid: false,           // Will be calculated below
406            equation_difference: Decimal::ZERO, // Will be calculated below
407            category_summary,
408            created_at: as_of_date.and_hms_opt(23, 59, 59).unwrap_or_default(),
409            created_by: format!(
410                "TrialBalanceGenerator (Consolidated from {} companies)",
411                trial_balances.len()
412            ),
413            approved_by: None,
414            approved_at: None,
415            status: TrialBalanceStatus::Draft,
416        };
417
418        // Calculate and set accounting equation validity
419        let (is_valid, _assets, _liabilities, _equity, diff) = tb.validate_accounting_equation();
420        tb.is_equation_valid = is_valid;
421        tb.equation_difference = diff;
422
423        tb
424    }
425
426    /// Splits a balance into debit and credit components.
427    fn split_balance(&self, balance: &AccountBalance) -> (Decimal, Decimal) {
428        let closing = balance.closing_balance;
429
430        // Determine natural balance side based on account type
431        match balance.account_type {
432            AccountType::Asset | AccountType::Expense => {
433                if closing >= Decimal::ZERO {
434                    (closing, Decimal::ZERO)
435                } else {
436                    (Decimal::ZERO, closing.abs())
437                }
438            }
439            AccountType::ContraAsset | AccountType::ContraLiability | AccountType::ContraEquity => {
440                // Contra accounts have opposite natural balance
441                if closing >= Decimal::ZERO {
442                    (Decimal::ZERO, closing)
443                } else {
444                    (closing.abs(), Decimal::ZERO)
445                }
446            }
447            AccountType::Liability | AccountType::Equity | AccountType::Revenue => {
448                if closing >= Decimal::ZERO {
449                    (Decimal::ZERO, closing)
450                } else {
451                    (closing.abs(), Decimal::ZERO)
452                }
453            }
454        }
455    }
456
457    /// Determines account category from code prefix.
458    fn determine_category(&self, account_code: &str) -> AccountCategory {
459        // Check registered mappings first
460        if let Some(category) = self.category_mappings.get(account_code) {
461            return *category;
462        }
463
464        // Default logic based on account code ranges
465        let prefix: u32 = account_code
466            .chars()
467            .take(2)
468            .collect::<String>()
469            .parse()
470            .unwrap_or(0);
471
472        match prefix {
473            10..=14 => AccountCategory::CurrentAssets,
474            15..=19 => AccountCategory::NonCurrentAssets,
475            20..=24 => AccountCategory::CurrentLiabilities,
476            25..=29 => AccountCategory::NonCurrentLiabilities,
477            30..=39 => AccountCategory::Equity,
478            40..=44 => AccountCategory::Revenue,
479            50..=54 => AccountCategory::CostOfGoodsSold,
480            55..=69 => AccountCategory::OperatingExpenses,
481            70..=74 => AccountCategory::OtherIncome,
482            75..=99 => AccountCategory::OtherExpenses,
483            _ => AccountCategory::OtherExpenses,
484        }
485    }
486
487    /// Calculates category summaries from lines.
488    fn calculate_category_summary(
489        &self,
490        lines: &[TrialBalanceLine],
491    ) -> HashMap<AccountCategory, CategorySummary> {
492        let mut summaries: HashMap<AccountCategory, CategorySummary> = HashMap::new();
493
494        for line in lines {
495            let summary = summaries
496                .entry(line.category)
497                .or_insert_with(|| CategorySummary::new(line.category));
498
499            summary.add_balance(line.debit_balance, line.credit_balance);
500        }
501
502        summaries
503    }
504
505    /// Finalizes a trial balance (changes status to Final).
506    pub fn finalize(&self, mut trial_balance: TrialBalance) -> TrialBalance {
507        trial_balance.status = TrialBalanceStatus::Final;
508        trial_balance
509    }
510
511    /// Approves a trial balance.
512    pub fn approve(&self, mut trial_balance: TrialBalance, approver: &str) -> TrialBalance {
513        trial_balance.status = TrialBalanceStatus::Approved;
514        trial_balance.approved_by = Some(approver.to_string());
515        trial_balance.approved_at = Some(
516            trial_balance
517                .as_of_date
518                .succ_opt()
519                .unwrap_or(trial_balance.as_of_date)
520                .and_hms_opt(9, 0, 0)
521                .unwrap_or_default(),
522        );
523        trial_balance
524    }
525}
526
527/// Builder for trial balance generation with fluent API.
528pub struct TrialBalanceBuilder {
529    generator: TrialBalanceGenerator,
530    snapshots: Vec<(String, BalanceSnapshot)>,
531    fiscal_year: i32,
532    fiscal_period: u32,
533}
534
535impl TrialBalanceBuilder {
536    /// Creates a new builder.
537    pub fn new(fiscal_year: i32, fiscal_period: u32) -> Self {
538        Self {
539            generator: TrialBalanceGenerator::with_defaults(),
540            snapshots: Vec::new(),
541            fiscal_year,
542            fiscal_period,
543        }
544    }
545
546    /// Adds a balance snapshot.
547    pub fn add_snapshot(mut self, company_code: &str, snapshot: BalanceSnapshot) -> Self {
548        self.snapshots.push((company_code.to_string(), snapshot));
549        self
550    }
551
552    /// Sets configuration.
553    pub fn with_config(mut self, config: TrialBalanceConfig) -> Self {
554        self.generator = TrialBalanceGenerator::new(config);
555        self
556    }
557
558    /// Builds individual trial balances.
559    pub fn build(self) -> Vec<TrialBalance> {
560        self.snapshots
561            .iter()
562            .map(|(_, snapshot)| {
563                self.generator.generate_from_snapshot(
564                    snapshot,
565                    self.fiscal_year,
566                    self.fiscal_period,
567                )
568            })
569            .collect()
570    }
571
572    /// Builds a consolidated trial balance.
573    pub fn build_consolidated(self, consolidated_code: &str) -> TrialBalance {
574        let individual = self
575            .snapshots
576            .iter()
577            .map(|(_, snapshot)| {
578                self.generator.generate_from_snapshot(
579                    snapshot,
580                    self.fiscal_year,
581                    self.fiscal_period,
582                )
583            })
584            .collect::<Vec<_>>();
585
586        self.generator
587            .generate_consolidated(&individual, consolidated_code)
588    }
589}
590
591#[cfg(test)]
592#[allow(clippy::unwrap_used)]
593mod tests {
594    use super::*;
595
596    fn create_test_balance(
597        company: &str,
598        account: &str,
599        acct_type: AccountType,
600        opening: Decimal,
601    ) -> AccountBalance {
602        let mut bal = AccountBalance::new(
603            company.to_string(),
604            account.to_string(),
605            acct_type,
606            "USD".to_string(),
607            2024,
608            1,
609        );
610        bal.opening_balance = opening;
611        bal.closing_balance = opening;
612        bal
613    }
614
615    fn create_test_snapshot() -> BalanceSnapshot {
616        let mut snapshot = BalanceSnapshot::new(
617            "SNAP-TEST-2024-01".to_string(),
618            "TEST".to_string(),
619            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
620            2024,
621            1,
622            "USD".to_string(),
623        );
624
625        // Add assets
626        snapshot.balances.insert(
627            "1100".to_string(),
628            create_test_balance("TEST", "1100", AccountType::Asset, dec!(10000)),
629        );
630
631        // Add liabilities
632        snapshot.balances.insert(
633            "2100".to_string(),
634            create_test_balance("TEST", "2100", AccountType::Liability, dec!(5000)),
635        );
636
637        // Add equity
638        snapshot.balances.insert(
639            "3100".to_string(),
640            create_test_balance("TEST", "3100", AccountType::Equity, dec!(5000)),
641        );
642
643        snapshot.recalculate_totals();
644        snapshot
645    }
646
647    #[test]
648    fn test_generate_trial_balance() {
649        let generator = TrialBalanceGenerator::with_defaults();
650        let snapshot = create_test_snapshot();
651
652        let tb = generator.generate_from_snapshot(&snapshot, 2024, 1);
653
654        assert!(tb.is_balanced);
655        assert_eq!(tb.lines.len(), 3);
656        assert_eq!(tb.total_debits, dec!(10000));
657        assert_eq!(tb.total_credits, dec!(10000));
658    }
659
660    #[test]
661    fn test_category_summaries() {
662        let generator = TrialBalanceGenerator::with_defaults();
663        let snapshot = create_test_snapshot();
664
665        let tb = generator.generate_from_snapshot(&snapshot, 2024, 1);
666
667        assert!(!tb.category_summary.is_empty());
668    }
669
670    #[test]
671    fn test_consolidated_trial_balance() {
672        let generator = TrialBalanceGenerator::with_defaults();
673
674        let snapshot1 = create_test_snapshot();
675        let mut snapshot2 = BalanceSnapshot::new(
676            "SNAP-TEST2-2024-01".to_string(),
677            "TEST2".to_string(),
678            snapshot1.as_of_date,
679            2024,
680            1,
681            "USD".to_string(),
682        );
683
684        // Copy and double the balances
685        for (code, balance) in &snapshot1.balances {
686            let mut new_bal = balance.clone();
687            new_bal.company_code = "TEST2".to_string();
688            new_bal.closing_balance *= dec!(2);
689            new_bal.opening_balance *= dec!(2);
690            snapshot2.balances.insert(code.clone(), new_bal);
691        }
692        snapshot2.recalculate_totals();
693
694        let tb1 = generator.generate_from_snapshot(&snapshot1, 2024, 1);
695        let tb2 = generator.generate_from_snapshot(&snapshot2, 2024, 1);
696
697        let consolidated = generator.generate_consolidated(&[tb1, tb2], "CONSOL");
698
699        assert_eq!(consolidated.company_code, "CONSOL");
700        assert!(consolidated.is_balanced);
701    }
702
703    #[test]
704    fn test_builder_pattern() {
705        let snapshot = create_test_snapshot();
706
707        let trial_balances = TrialBalanceBuilder::new(2024, 1)
708            .add_snapshot("TEST", snapshot)
709            .build();
710
711        assert_eq!(trial_balances.len(), 1);
712        assert!(trial_balances[0].is_balanced);
713    }
714}