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