Skip to main content

datasynth_generators/period_close/
financial_statement_generator.rs

1//! Financial statement generator.
2//!
3//! Generates financial statements from adjusted trial balance data:
4//! - Balance Sheet: Assets = Liabilities + Equity
5//! - Income Statement: Revenue - COGS - OpEx - Tax = Net Income
6//! - Cash Flow Statement (indirect method)
7//! - Statement of Changes in Equity
8
9use chrono::NaiveDate;
10use datasynth_config::schema::FinancialReportingConfig;
11use datasynth_core::models::{
12    CashFlowCategory, CashFlowItem, FinancialStatement, FinancialStatementLineItem, StatementBasis,
13    StatementType,
14};
15use datasynth_core::utils::seeded_rng;
16use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
17use rand::prelude::*;
18use rand_chacha::ChaCha8Rng;
19use rust_decimal::Decimal;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use tracing::debug;
23
24/// Generates financial statements from trial balance data.
25pub struct FinancialStatementGenerator {
26    rng: ChaCha8Rng,
27    uuid_factory: DeterministicUuidFactory,
28    config: FinancialReportingConfig,
29}
30
31/// Trial balance entry for statement generation.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TrialBalanceEntry {
34    /// GL account code
35    pub account_code: String,
36    /// Account description
37    pub account_name: String,
38    /// Account category (Asset, Liability, Equity, Revenue, Expense)
39    pub category: String,
40    /// Debit balance
41    pub debit_balance: Decimal,
42    /// Credit balance
43    pub credit_balance: Decimal,
44}
45
46impl FinancialStatementGenerator {
47    /// Create a new financial statement generator.
48    pub fn new(seed: u64) -> Self {
49        Self {
50            rng: seeded_rng(seed, 0),
51            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::FinancialStatement),
52            config: FinancialReportingConfig::default(),
53        }
54    }
55
56    /// Create with custom configuration.
57    pub fn with_config(seed: u64, config: FinancialReportingConfig) -> Self {
58        Self {
59            rng: seeded_rng(seed, 0),
60            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::FinancialStatement),
61            config,
62        }
63    }
64
65    /// Generate all financial statements for a period.
66    pub fn generate(
67        &mut self,
68        company_code: &str,
69        currency: &str,
70        trial_balance: &[TrialBalanceEntry],
71        period_start: NaiveDate,
72        period_end: NaiveDate,
73        fiscal_year: u16,
74        fiscal_period: u8,
75        prior_trial_balance: Option<&[TrialBalanceEntry]>,
76        preparer_id: &str,
77    ) -> Vec<FinancialStatement> {
78        debug!(
79            company_code,
80            currency,
81            fiscal_year,
82            fiscal_period,
83            tb_entries = trial_balance.len(),
84            "Generating financial statements"
85        );
86        let mut statements = Vec::new();
87
88        if self.config.generate_balance_sheet {
89            statements.push(self.generate_balance_sheet(
90                company_code,
91                currency,
92                trial_balance,
93                period_start,
94                period_end,
95                fiscal_year,
96                fiscal_period,
97                prior_trial_balance,
98                preparer_id,
99            ));
100        }
101
102        if self.config.generate_income_statement {
103            statements.push(self.generate_income_statement(
104                company_code,
105                currency,
106                trial_balance,
107                period_start,
108                period_end,
109                fiscal_year,
110                fiscal_period,
111                prior_trial_balance,
112                preparer_id,
113            ));
114        }
115
116        if self.config.generate_cash_flow {
117            let net_income = self.calculate_net_income(trial_balance);
118            statements.push(self.generate_cash_flow_statement(
119                company_code,
120                currency,
121                trial_balance,
122                period_start,
123                period_end,
124                fiscal_year,
125                fiscal_period,
126                net_income,
127                preparer_id,
128            ));
129        }
130
131        statements
132    }
133
134    fn generate_balance_sheet(
135        &mut self,
136        company_code: &str,
137        currency: &str,
138        tb: &[TrialBalanceEntry],
139        period_start: NaiveDate,
140        period_end: NaiveDate,
141        fiscal_year: u16,
142        fiscal_period: u8,
143        prior_tb: Option<&[TrialBalanceEntry]>,
144        preparer_id: &str,
145    ) -> FinancialStatement {
146        let mut line_items = Vec::new();
147        let mut sort_order = 0u32;
148
149        // Aggregate by category
150        let aggregated = self.aggregate_by_category(tb);
151        let prior_aggregated = prior_tb.map(|ptb| self.aggregate_by_category(ptb));
152
153        let get_prior = |key: &str| -> Option<Decimal> {
154            prior_aggregated
155                .as_ref()
156                .and_then(|pa| pa.get(key).copied())
157        };
158
159        // Assets
160        let cash = *aggregated.get("Cash").unwrap_or(&Decimal::ZERO);
161        let ar = *aggregated.get("Receivables").unwrap_or(&Decimal::ZERO);
162        let inventory = *aggregated.get("Inventory").unwrap_or(&Decimal::ZERO);
163        let current_assets = cash + ar + inventory;
164        let fixed_assets = *aggregated.get("FixedAssets").unwrap_or(&Decimal::ZERO);
165        let total_assets = current_assets + fixed_assets;
166
167        let items_data = [
168            (
169                "BS-CASH",
170                "Cash and Cash Equivalents",
171                "Current Assets",
172                cash,
173                get_prior("Cash"),
174                0,
175                false,
176            ),
177            (
178                "BS-AR",
179                "Accounts Receivable",
180                "Current Assets",
181                ar,
182                get_prior("Receivables"),
183                0,
184                false,
185            ),
186            (
187                "BS-INV",
188                "Inventory",
189                "Current Assets",
190                inventory,
191                get_prior("Inventory"),
192                0,
193                false,
194            ),
195            (
196                "BS-CA",
197                "Total Current Assets",
198                "Current Assets",
199                current_assets,
200                None,
201                0,
202                true,
203            ),
204            (
205                "BS-FA",
206                "Property, Plant & Equipment, net",
207                "Non-Current Assets",
208                fixed_assets,
209                get_prior("FixedAssets"),
210                0,
211                false,
212            ),
213            (
214                "BS-TA",
215                "Total Assets",
216                "Total Assets",
217                total_assets,
218                None,
219                0,
220                true,
221            ),
222        ];
223
224        for (code, label, section, amount, prior, indent, is_total) in &items_data {
225            sort_order += 1;
226            line_items.push(FinancialStatementLineItem {
227                line_code: code.to_string(),
228                label: label.to_string(),
229                section: section.to_string(),
230                sort_order,
231                amount: *amount,
232                amount_prior: *prior,
233                indent_level: *indent,
234                is_total: *is_total,
235                gl_accounts: Vec::new(),
236            });
237        }
238
239        // Liabilities & Equity
240        let ap = *aggregated.get("Payables").unwrap_or(&Decimal::ZERO);
241        let accrued = *aggregated
242            .get("AccruedLiabilities")
243            .unwrap_or(&Decimal::ZERO);
244        let current_liabilities = ap + accrued;
245        let lt_debt = *aggregated.get("LongTermDebt").unwrap_or(&Decimal::ZERO);
246        let total_liabilities = current_liabilities + lt_debt;
247
248        let retained_earnings = total_assets - total_liabilities;
249        let total_equity = retained_earnings;
250        let total_le = total_liabilities + total_equity;
251
252        let le_items = [
253            (
254                "BS-AP",
255                "Accounts Payable",
256                "Current Liabilities",
257                ap,
258                get_prior("Payables"),
259                0,
260                false,
261            ),
262            (
263                "BS-ACR",
264                "Accrued Liabilities",
265                "Current Liabilities",
266                accrued,
267                get_prior("AccruedLiabilities"),
268                0,
269                false,
270            ),
271            (
272                "BS-CL",
273                "Total Current Liabilities",
274                "Current Liabilities",
275                current_liabilities,
276                None,
277                0,
278                true,
279            ),
280            (
281                "BS-LTD",
282                "Long-Term Debt",
283                "Non-Current Liabilities",
284                lt_debt,
285                get_prior("LongTermDebt"),
286                0,
287                false,
288            ),
289            (
290                "BS-TL",
291                "Total Liabilities",
292                "Total Liabilities",
293                total_liabilities,
294                None,
295                0,
296                true,
297            ),
298            (
299                "BS-RE",
300                "Retained Earnings",
301                "Equity",
302                retained_earnings,
303                None,
304                0,
305                false,
306            ),
307            (
308                "BS-TE",
309                "Total Equity",
310                "Equity",
311                total_equity,
312                None,
313                0,
314                true,
315            ),
316            (
317                "BS-TLE",
318                "Total Liabilities & Equity",
319                "Total",
320                total_le,
321                None,
322                0,
323                true,
324            ),
325        ];
326
327        for (code, label, section, amount, prior, indent, is_total) in &le_items {
328            sort_order += 1;
329            line_items.push(FinancialStatementLineItem {
330                line_code: code.to_string(),
331                label: label.to_string(),
332                section: section.to_string(),
333                sort_order,
334                amount: *amount,
335                amount_prior: *prior,
336                indent_level: *indent,
337                is_total: *is_total,
338                gl_accounts: Vec::new(),
339            });
340        }
341
342        FinancialStatement {
343            statement_id: self.uuid_factory.next().to_string(),
344            company_code: company_code.to_string(),
345            statement_type: StatementType::BalanceSheet,
346            basis: StatementBasis::UsGaap,
347            period_start,
348            period_end,
349            fiscal_year,
350            fiscal_period,
351            line_items,
352            cash_flow_items: Vec::new(),
353            currency: currency.to_string(),
354            is_consolidated: false,
355            preparer_id: preparer_id.to_string(),
356        }
357    }
358
359    fn generate_income_statement(
360        &mut self,
361        company_code: &str,
362        currency: &str,
363        tb: &[TrialBalanceEntry],
364        period_start: NaiveDate,
365        period_end: NaiveDate,
366        fiscal_year: u16,
367        fiscal_period: u8,
368        prior_tb: Option<&[TrialBalanceEntry]>,
369        preparer_id: &str,
370    ) -> FinancialStatement {
371        let aggregated = self.aggregate_by_category(tb);
372        let prior_aggregated = prior_tb.map(|ptb| self.aggregate_by_category(ptb));
373
374        let get_prior = |key: &str| -> Option<Decimal> {
375            prior_aggregated
376                .as_ref()
377                .and_then(|pa| pa.get(key).copied())
378        };
379
380        let revenue = *aggregated.get("Revenue").unwrap_or(&Decimal::ZERO);
381        let cogs = *aggregated.get("CostOfSales").unwrap_or(&Decimal::ZERO);
382        let gross_profit = revenue - cogs;
383        let operating_expenses = *aggregated
384            .get("OperatingExpenses")
385            .unwrap_or(&Decimal::ZERO);
386        let operating_income = gross_profit - operating_expenses;
387        let tax = operating_income * Decimal::from_f64_retain(0.25).unwrap_or(Decimal::ZERO);
388        let net_income = operating_income - tax;
389
390        let mut line_items = Vec::new();
391        let items_data = [
392            (
393                "IS-REV",
394                "Revenue",
395                "Revenue",
396                revenue,
397                get_prior("Revenue"),
398                false,
399            ),
400            (
401                "IS-COGS",
402                "Cost of Goods Sold",
403                "Cost of Sales",
404                cogs,
405                get_prior("CostOfSales"),
406                false,
407            ),
408            (
409                "IS-GP",
410                "Gross Profit",
411                "Gross Profit",
412                gross_profit,
413                None,
414                true,
415            ),
416            (
417                "IS-OPEX",
418                "Operating Expenses",
419                "Operating Expenses",
420                operating_expenses,
421                get_prior("OperatingExpenses"),
422                false,
423            ),
424            (
425                "IS-OI",
426                "Operating Income",
427                "Operating Income",
428                operating_income,
429                None,
430                true,
431            ),
432            ("IS-TAX", "Income Tax Expense", "Tax", tax, None, false),
433            ("IS-NI", "Net Income", "Net Income", net_income, None, true),
434        ];
435
436        for (i, (code, label, section, amount, prior, is_total)) in items_data.iter().enumerate() {
437            line_items.push(FinancialStatementLineItem {
438                line_code: code.to_string(),
439                label: label.to_string(),
440                section: section.to_string(),
441                sort_order: (i + 1) as u32,
442                amount: *amount,
443                amount_prior: *prior,
444                indent_level: 0,
445                is_total: *is_total,
446                gl_accounts: Vec::new(),
447            });
448        }
449
450        FinancialStatement {
451            statement_id: self.uuid_factory.next().to_string(),
452            company_code: company_code.to_string(),
453            statement_type: StatementType::IncomeStatement,
454            basis: StatementBasis::UsGaap,
455            period_start,
456            period_end,
457            fiscal_year,
458            fiscal_period,
459            line_items,
460            cash_flow_items: Vec::new(),
461            currency: currency.to_string(),
462            is_consolidated: false,
463            preparer_id: preparer_id.to_string(),
464        }
465    }
466
467    fn generate_cash_flow_statement(
468        &mut self,
469        company_code: &str,
470        currency: &str,
471        _tb: &[TrialBalanceEntry],
472        period_start: NaiveDate,
473        period_end: NaiveDate,
474        fiscal_year: u16,
475        fiscal_period: u8,
476        net_income: Decimal,
477        preparer_id: &str,
478    ) -> FinancialStatement {
479        // Indirect method: start with net income, adjust for non-cash items
480        let depreciation = Decimal::from(self.rng.random_range(5000..=50000));
481        let ar_change = Decimal::from(self.rng.random_range(-20000i64..=20000));
482        let ap_change = Decimal::from(self.rng.random_range(-15000i64..=15000));
483        let inventory_change = Decimal::from(self.rng.random_range(-10000i64..=10000));
484
485        let operating_cf = net_income + depreciation - ar_change + ap_change - inventory_change;
486
487        let capex = Decimal::from(self.rng.random_range(-100000i64..=-5000));
488        let investing_cf = capex;
489
490        let debt_change = Decimal::from(self.rng.random_range(-50000i64..=50000));
491        let financing_cf = debt_change;
492
493        let net_change = operating_cf + investing_cf + financing_cf;
494
495        let cash_flow_items = vec![
496            CashFlowItem {
497                item_code: "CF-NI".to_string(),
498                label: "Net Income".to_string(),
499                category: CashFlowCategory::Operating,
500                amount: net_income,
501                amount_prior: None,
502                sort_order: 1,
503                is_total: false,
504            },
505            CashFlowItem {
506                item_code: "CF-DEP".to_string(),
507                label: "Depreciation & Amortization".to_string(),
508                category: CashFlowCategory::Operating,
509                amount: depreciation,
510                amount_prior: None,
511                sort_order: 2,
512                is_total: false,
513            },
514            CashFlowItem {
515                item_code: "CF-AR".to_string(),
516                label: "Change in Accounts Receivable".to_string(),
517                category: CashFlowCategory::Operating,
518                amount: -ar_change,
519                amount_prior: None,
520                sort_order: 3,
521                is_total: false,
522            },
523            CashFlowItem {
524                item_code: "CF-AP".to_string(),
525                label: "Change in Accounts Payable".to_string(),
526                category: CashFlowCategory::Operating,
527                amount: ap_change,
528                amount_prior: None,
529                sort_order: 4,
530                is_total: false,
531            },
532            CashFlowItem {
533                item_code: "CF-INV".to_string(),
534                label: "Change in Inventory".to_string(),
535                category: CashFlowCategory::Operating,
536                amount: -inventory_change,
537                amount_prior: None,
538                sort_order: 5,
539                is_total: false,
540            },
541            CashFlowItem {
542                item_code: "CF-OP".to_string(),
543                label: "Net Cash from Operating Activities".to_string(),
544                category: CashFlowCategory::Operating,
545                amount: operating_cf,
546                amount_prior: None,
547                sort_order: 6,
548                is_total: true,
549            },
550            CashFlowItem {
551                item_code: "CF-CAPEX".to_string(),
552                label: "Capital Expenditures".to_string(),
553                category: CashFlowCategory::Investing,
554                amount: capex,
555                amount_prior: None,
556                sort_order: 7,
557                is_total: false,
558            },
559            CashFlowItem {
560                item_code: "CF-INV-T".to_string(),
561                label: "Net Cash from Investing Activities".to_string(),
562                category: CashFlowCategory::Investing,
563                amount: investing_cf,
564                amount_prior: None,
565                sort_order: 8,
566                is_total: true,
567            },
568            CashFlowItem {
569                item_code: "CF-DEBT".to_string(),
570                label: "Net Borrowings / (Repayments)".to_string(),
571                category: CashFlowCategory::Financing,
572                amount: debt_change,
573                amount_prior: None,
574                sort_order: 9,
575                is_total: false,
576            },
577            CashFlowItem {
578                item_code: "CF-FIN-T".to_string(),
579                label: "Net Cash from Financing Activities".to_string(),
580                category: CashFlowCategory::Financing,
581                amount: financing_cf,
582                amount_prior: None,
583                sort_order: 10,
584                is_total: true,
585            },
586            CashFlowItem {
587                item_code: "CF-NET".to_string(),
588                label: "Net Change in Cash".to_string(),
589                category: CashFlowCategory::Operating,
590                amount: net_change,
591                amount_prior: None,
592                sort_order: 11,
593                is_total: true,
594            },
595        ];
596
597        FinancialStatement {
598            statement_id: self.uuid_factory.next().to_string(),
599            company_code: company_code.to_string(),
600            statement_type: StatementType::CashFlowStatement,
601            basis: StatementBasis::UsGaap,
602            period_start,
603            period_end,
604            fiscal_year,
605            fiscal_period,
606            line_items: Vec::new(),
607            cash_flow_items,
608            currency: currency.to_string(),
609            is_consolidated: false,
610            preparer_id: preparer_id.to_string(),
611        }
612    }
613
614    fn calculate_net_income(&self, tb: &[TrialBalanceEntry]) -> Decimal {
615        let aggregated = self.aggregate_by_category(tb);
616        let revenue = *aggregated.get("Revenue").unwrap_or(&Decimal::ZERO);
617        let cogs = *aggregated.get("CostOfSales").unwrap_or(&Decimal::ZERO);
618        let opex = *aggregated
619            .get("OperatingExpenses")
620            .unwrap_or(&Decimal::ZERO);
621        let operating_income = revenue - cogs - opex;
622        let tax = operating_income * Decimal::from_f64_retain(0.25).unwrap_or(Decimal::ZERO);
623        operating_income - tax
624    }
625
626    fn aggregate_by_category(&self, tb: &[TrialBalanceEntry]) -> HashMap<String, Decimal> {
627        let mut aggregated: HashMap<String, Decimal> = HashMap::new();
628        for entry in tb {
629            let net = entry.debit_balance - entry.credit_balance;
630            *aggregated.entry(entry.category.clone()).or_default() += net;
631        }
632        aggregated
633    }
634}
635
636#[cfg(test)]
637#[allow(clippy::unwrap_used)]
638mod tests {
639    use super::*;
640
641    fn test_trial_balance() -> Vec<TrialBalanceEntry> {
642        vec![
643            TrialBalanceEntry {
644                account_code: "1000".to_string(),
645                account_name: "Cash".to_string(),
646                category: "Cash".to_string(),
647                debit_balance: Decimal::from(500_000),
648                credit_balance: Decimal::ZERO,
649            },
650            TrialBalanceEntry {
651                account_code: "1100".to_string(),
652                account_name: "Accounts Receivable".to_string(),
653                category: "Receivables".to_string(),
654                debit_balance: Decimal::from(200_000),
655                credit_balance: Decimal::ZERO,
656            },
657            TrialBalanceEntry {
658                account_code: "1300".to_string(),
659                account_name: "Inventory".to_string(),
660                category: "Inventory".to_string(),
661                debit_balance: Decimal::from(150_000),
662                credit_balance: Decimal::ZERO,
663            },
664            TrialBalanceEntry {
665                account_code: "1500".to_string(),
666                account_name: "Fixed Assets".to_string(),
667                category: "FixedAssets".to_string(),
668                debit_balance: Decimal::from(800_000),
669                credit_balance: Decimal::ZERO,
670            },
671            TrialBalanceEntry {
672                account_code: "2000".to_string(),
673                account_name: "Accounts Payable".to_string(),
674                category: "Payables".to_string(),
675                debit_balance: Decimal::ZERO,
676                credit_balance: Decimal::from(120_000),
677            },
678            TrialBalanceEntry {
679                account_code: "2100".to_string(),
680                account_name: "Accrued Liabilities".to_string(),
681                category: "AccruedLiabilities".to_string(),
682                debit_balance: Decimal::ZERO,
683                credit_balance: Decimal::from(80_000),
684            },
685            TrialBalanceEntry {
686                account_code: "4000".to_string(),
687                account_name: "Revenue".to_string(),
688                category: "Revenue".to_string(),
689                debit_balance: Decimal::ZERO,
690                credit_balance: Decimal::from(1_000_000),
691            },
692            TrialBalanceEntry {
693                account_code: "5000".to_string(),
694                account_name: "Cost of Goods Sold".to_string(),
695                category: "CostOfSales".to_string(),
696                debit_balance: Decimal::from(600_000),
697                credit_balance: Decimal::ZERO,
698            },
699            TrialBalanceEntry {
700                account_code: "6000".to_string(),
701                account_name: "Operating Expenses".to_string(),
702                category: "OperatingExpenses".to_string(),
703                debit_balance: Decimal::from(250_000),
704                credit_balance: Decimal::ZERO,
705            },
706        ]
707    }
708
709    #[test]
710    fn test_basic_generation() {
711        let mut gen = FinancialStatementGenerator::new(42);
712        let tb = test_trial_balance();
713        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
714        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
715
716        let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
717
718        // Default config generates all 3 statement types
719        assert_eq!(statements.len(), 3);
720
721        let bs = statements
722            .iter()
723            .find(|s| s.statement_type == StatementType::BalanceSheet)
724            .unwrap();
725        let is = statements
726            .iter()
727            .find(|s| s.statement_type == StatementType::IncomeStatement)
728            .unwrap();
729        let cf = statements
730            .iter()
731            .find(|s| s.statement_type == StatementType::CashFlowStatement)
732            .unwrap();
733
734        // Balance sheet checks
735        assert!(!bs.statement_id.is_empty());
736        assert_eq!(bs.company_code, "C001");
737        assert_eq!(bs.currency, "USD");
738        assert!(!bs.line_items.is_empty());
739        assert_eq!(bs.fiscal_year, 2024);
740        assert_eq!(bs.fiscal_period, 1);
741        assert_eq!(bs.preparer_id, "PREP-01");
742
743        // Income statement checks
744        assert!(!is.statement_id.is_empty());
745        assert!(!is.line_items.is_empty());
746
747        // Cash flow checks
748        assert!(!cf.statement_id.is_empty());
749        assert!(!cf.cash_flow_items.is_empty());
750    }
751
752    #[test]
753    fn test_deterministic() {
754        let tb = test_trial_balance();
755        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
756        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
757
758        let mut gen1 = FinancialStatementGenerator::new(42);
759        let mut gen2 = FinancialStatementGenerator::new(42);
760
761        let r1 = gen1.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
762        let r2 = gen2.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
763
764        assert_eq!(r1.len(), r2.len());
765        for (a, b) in r1.iter().zip(r2.iter()) {
766            assert_eq!(a.statement_id, b.statement_id);
767            assert_eq!(a.statement_type, b.statement_type);
768            assert_eq!(a.line_items.len(), b.line_items.len());
769            assert_eq!(a.cash_flow_items.len(), b.cash_flow_items.len());
770
771            for (li_a, li_b) in a.line_items.iter().zip(b.line_items.iter()) {
772                assert_eq!(li_a.line_code, li_b.line_code);
773                assert_eq!(li_a.amount, li_b.amount);
774            }
775            for (cf_a, cf_b) in a.cash_flow_items.iter().zip(b.cash_flow_items.iter()) {
776                assert_eq!(cf_a.item_code, cf_b.item_code);
777                assert_eq!(cf_a.amount, cf_b.amount);
778            }
779        }
780    }
781
782    #[test]
783    fn test_balance_sheet_balances() {
784        let mut gen = FinancialStatementGenerator::new(42);
785        let tb = test_trial_balance();
786        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
787        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
788
789        let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
790        let bs = statements
791            .iter()
792            .find(|s| s.statement_type == StatementType::BalanceSheet)
793            .unwrap();
794
795        // Find Total Assets and Total Liabilities & Equity
796        let total_assets = bs
797            .line_items
798            .iter()
799            .find(|li| li.line_code == "BS-TA")
800            .unwrap();
801        let total_le = bs
802            .line_items
803            .iter()
804            .find(|li| li.line_code == "BS-TLE")
805            .unwrap();
806
807        // Assets = Liabilities + Equity (balance sheet must balance)
808        assert_eq!(
809            total_assets.amount, total_le.amount,
810            "Balance sheet does not balance: Assets={} vs L+E={}",
811            total_assets.amount, total_le.amount
812        );
813    }
814
815    #[test]
816    fn test_income_statement_structure() {
817        let mut gen = FinancialStatementGenerator::new(42);
818        let tb = test_trial_balance();
819        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
820        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
821
822        let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
823        let is = statements
824            .iter()
825            .find(|s| s.statement_type == StatementType::IncomeStatement)
826            .unwrap();
827
828        // Check expected line items exist
829        let codes: Vec<&str> = is
830            .line_items
831            .iter()
832            .map(|li| li.line_code.as_str())
833            .collect();
834        assert!(codes.contains(&"IS-REV"));
835        assert!(codes.contains(&"IS-COGS"));
836        assert!(codes.contains(&"IS-GP"));
837        assert!(codes.contains(&"IS-OPEX"));
838        assert!(codes.contains(&"IS-OI"));
839        assert!(codes.contains(&"IS-TAX"));
840        assert!(codes.contains(&"IS-NI"));
841
842        // Revenue should be negative (credit balance in TB becomes negative net)
843        let revenue = is
844            .line_items
845            .iter()
846            .find(|li| li.line_code == "IS-REV")
847            .unwrap();
848        // Revenue category has credit > debit, so net = debit - credit = -1,000,000
849        assert_eq!(revenue.amount, Decimal::from(-1_000_000));
850    }
851}