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