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        // Retained earnings is used as a plug to force A = L + E (i.e. the balance sheet always
251        // balances by construction).  This is an intentional simplification: proper equity
252        // modelling with share capital, additional paid-in capital, treasury shares, AOCI, and
253        // NCI would require either multi-period state or a dedicated equity roll-forward step.
254        // TODO (Fix 11): Replace the plug with actual equity components when the equity
255        // roll-forward is implemented.
256        let retained_earnings = total_assets - total_liabilities;
257        let total_equity = retained_earnings;
258        let total_le = total_liabilities + total_equity;
259
260        let le_items = [
261            (
262                "BS-AP",
263                "Accounts Payable",
264                "Current Liabilities",
265                ap,
266                get_prior("Payables"),
267                0,
268                false,
269            ),
270            (
271                "BS-ACR",
272                "Accrued Liabilities",
273                "Current Liabilities",
274                accrued,
275                get_prior("AccruedLiabilities"),
276                0,
277                false,
278            ),
279            (
280                "BS-CL",
281                "Total Current Liabilities",
282                "Current Liabilities",
283                current_liabilities,
284                None,
285                0,
286                true,
287            ),
288            (
289                "BS-LTD",
290                "Long-Term Debt",
291                "Non-Current Liabilities",
292                lt_debt,
293                get_prior("LongTermDebt"),
294                0,
295                false,
296            ),
297            (
298                "BS-TL",
299                "Total Liabilities",
300                "Total Liabilities",
301                total_liabilities,
302                None,
303                0,
304                true,
305            ),
306            (
307                "BS-RE",
308                "Retained Earnings",
309                "Equity",
310                retained_earnings,
311                None,
312                0,
313                false,
314            ),
315            (
316                "BS-TE",
317                "Total Equity",
318                "Equity",
319                total_equity,
320                None,
321                0,
322                true,
323            ),
324            (
325                "BS-TLE",
326                "Total Liabilities & Equity",
327                "Total",
328                total_le,
329                None,
330                0,
331                true,
332            ),
333        ];
334
335        for (code, label, section, amount, prior, indent, is_total) in &le_items {
336            sort_order += 1;
337            line_items.push(FinancialStatementLineItem {
338                line_code: code.to_string(),
339                label: label.to_string(),
340                section: section.to_string(),
341                sort_order,
342                amount: *amount,
343                amount_prior: *prior,
344                indent_level: *indent,
345                is_total: *is_total,
346                gl_accounts: Vec::new(),
347            });
348        }
349
350        FinancialStatement {
351            statement_id: self.uuid_factory.next().to_string(),
352            company_code: company_code.to_string(),
353            statement_type: StatementType::BalanceSheet,
354            basis: StatementBasis::UsGaap,
355            period_start,
356            period_end,
357            fiscal_year,
358            fiscal_period,
359            line_items,
360            cash_flow_items: Vec::new(),
361            currency: currency.to_string(),
362            is_consolidated: false,
363            preparer_id: preparer_id.to_string(),
364        }
365    }
366
367    fn generate_income_statement(
368        &mut self,
369        company_code: &str,
370        currency: &str,
371        tb: &[TrialBalanceEntry],
372        period_start: NaiveDate,
373        period_end: NaiveDate,
374        fiscal_year: u16,
375        fiscal_period: u8,
376        prior_tb: Option<&[TrialBalanceEntry]>,
377        preparer_id: &str,
378    ) -> FinancialStatement {
379        let aggregated = self.aggregate_by_category(tb);
380        let prior_aggregated = prior_tb.map(|ptb| self.aggregate_by_category(ptb));
381
382        let get_prior = |key: &str| -> Option<Decimal> {
383            prior_aggregated
384                .as_ref()
385                .and_then(|pa| pa.get(key).copied())
386        };
387
388        // Revenue accounts are credit-normal: aggregate_by_category returns debit - credit,
389        // which is negative for revenue. Negate so revenue appears as a positive amount on
390        // the income statement (a presentation convention, not an accounting sign change).
391        let revenue = -(*aggregated.get("Revenue").unwrap_or(&Decimal::ZERO));
392        let cogs = *aggregated.get("CostOfSales").unwrap_or(&Decimal::ZERO);
393        let gross_profit = revenue - cogs;
394        let operating_expenses = *aggregated
395            .get("OperatingExpenses")
396            .unwrap_or(&Decimal::ZERO);
397        let operating_income = gross_profit - operating_expenses;
398        let tax = operating_income * Decimal::new(21, 2); // 21% statutory rate
399        let net_income = operating_income - tax;
400
401        let mut line_items = Vec::new();
402        let items_data = [
403            (
404                "IS-REV",
405                "Revenue",
406                "Revenue",
407                revenue,
408                get_prior("Revenue"),
409                false,
410            ),
411            (
412                "IS-COGS",
413                "Cost of Goods Sold",
414                "Cost of Sales",
415                cogs,
416                get_prior("CostOfSales"),
417                false,
418            ),
419            (
420                "IS-GP",
421                "Gross Profit",
422                "Gross Profit",
423                gross_profit,
424                None,
425                true,
426            ),
427            (
428                "IS-OPEX",
429                "Operating Expenses",
430                "Operating Expenses",
431                operating_expenses,
432                get_prior("OperatingExpenses"),
433                false,
434            ),
435            (
436                "IS-OI",
437                "Operating Income",
438                "Operating Income",
439                operating_income,
440                None,
441                true,
442            ),
443            ("IS-TAX", "Income Tax Expense", "Tax", tax, None, false),
444            ("IS-NI", "Net Income", "Net Income", net_income, None, true),
445        ];
446
447        for (i, (code, label, section, amount, prior, is_total)) in items_data.iter().enumerate() {
448            line_items.push(FinancialStatementLineItem {
449                line_code: code.to_string(),
450                label: label.to_string(),
451                section: section.to_string(),
452                sort_order: (i + 1) as u32,
453                amount: *amount,
454                amount_prior: *prior,
455                indent_level: 0,
456                is_total: *is_total,
457                gl_accounts: Vec::new(),
458            });
459        }
460
461        FinancialStatement {
462            statement_id: self.uuid_factory.next().to_string(),
463            company_code: company_code.to_string(),
464            statement_type: StatementType::IncomeStatement,
465            basis: StatementBasis::UsGaap,
466            period_start,
467            period_end,
468            fiscal_year,
469            fiscal_period,
470            line_items,
471            cash_flow_items: Vec::new(),
472            currency: currency.to_string(),
473            is_consolidated: false,
474            preparer_id: preparer_id.to_string(),
475        }
476    }
477
478    fn generate_cash_flow_statement(
479        &mut self,
480        company_code: &str,
481        currency: &str,
482        tb: &[TrialBalanceEntry],
483        prior_tb: Option<&[TrialBalanceEntry]>,
484        period_start: NaiveDate,
485        period_end: NaiveDate,
486        fiscal_year: u16,
487        fiscal_period: u8,
488        net_income: Decimal,
489        preparer_id: &str,
490    ) -> FinancialStatement {
491        // Indirect method: start with net income, adjust for non-cash items.
492        // All values are derived from the trial balance (and prior TB for period changes).
493
494        let current = self.aggregate_by_category(tb);
495        let prior = prior_tb.map(|ptb| self.aggregate_by_category(ptb));
496
497        let get_current = |key: &str| -> Decimal { *current.get(key).unwrap_or(&Decimal::ZERO) };
498        let get_prior = |key: &str| -> Decimal {
499            prior
500                .as_ref()
501                .and_then(|p| p.get(key).copied())
502                .unwrap_or(Decimal::ZERO)
503        };
504
505        // Depreciation add-back: use current-period OperatingExpenses proxy as depreciation is
506        // embedded in expenses. We look for an explicit "Depreciation" category first, then
507        // fall back to 5% of fixed assets (a common stub when no detail is available).
508        let fa_current = get_current("FixedAssets");
509        let fa_prior = get_prior("FixedAssets");
510        let depreciation = if current.contains_key("Depreciation") {
511            get_current("Depreciation")
512        } else {
513            // Approximate: FA decrease net of additions implies depreciation
514            // Use a conservative 5% of average FA balance when no explicit data
515            let avg_fa = (fa_current.abs() + fa_prior.abs()) / Decimal::from(2);
516            (avg_fa * Decimal::new(5, 2)).max(Decimal::ZERO)
517        };
518
519        // Working capital changes (increase in asset = use of cash = negative)
520        // AR: increase in AR is a use of cash (negative)
521        let ar_current = get_current("Receivables");
522        let ar_prior = get_prior("Receivables");
523        let ar_change = ar_current - ar_prior; // positive = AR increased = use of cash
524
525        // Inventory: increase in inventory is a use of cash (negative)
526        let inv_current = get_current("Inventory");
527        let inv_prior = get_prior("Inventory");
528        let inventory_change = inv_current - inv_prior; // positive = built up inventory = use of cash
529
530        // AP: increase in AP is a source of cash (positive)
531        let ap_current = get_current("Payables");
532        let ap_prior = get_prior("Payables");
533        let ap_change = ap_current - ap_prior; // positive = AP increased = source of cash
534
535        // Accruals: increase in accruals is a source of cash (positive)
536        let accrual_current = get_current("AccruedLiabilities");
537        let accrual_prior = get_prior("AccruedLiabilities");
538        let accrual_change = accrual_current - accrual_prior;
539
540        // Operating CF = Net Income + Depreciation - ΔAR - ΔInventory + ΔAP + ΔAccruals
541        let operating_cf =
542            net_income + depreciation - ar_change - inventory_change + ap_change + accrual_change;
543
544        // Investing CF: net change in fixed assets (increase = outflow = negative)
545        let fa_change = fa_current - fa_prior; // positive = more FA = capex outflow
546        let capex = -fa_change; // negate: FA increase → negative investing CF
547        let investing_cf = capex;
548
549        // Financing CF: net change in long-term debt only.
550        // Equity changes are NOT included in financing CF because retained earnings
551        // changes are the result of net income (already in operating), and equity
552        // issuances/buybacks would require separate tracking (not available here).
553        // Retained earnings appear in "Changes in Equity", not the CF statement.
554        let debt_current = get_current("LongTermDebt");
555        let debt_prior = get_prior("LongTermDebt");
556        let debt_change = debt_current - debt_prior;
557
558        let financing_cf = debt_change;
559
560        let net_change = operating_cf + investing_cf + financing_cf;
561
562        let cash_flow_items = vec![
563            CashFlowItem {
564                item_code: "CF-NI".to_string(),
565                label: "Net Income".to_string(),
566                category: CashFlowCategory::Operating,
567                amount: net_income,
568                amount_prior: None,
569                sort_order: 1,
570                is_total: false,
571            },
572            CashFlowItem {
573                item_code: "CF-DEP".to_string(),
574                label: "Depreciation & Amortization".to_string(),
575                category: CashFlowCategory::Operating,
576                amount: depreciation,
577                amount_prior: None,
578                sort_order: 2,
579                is_total: false,
580            },
581            CashFlowItem {
582                item_code: "CF-AR".to_string(),
583                label: "Change in Accounts Receivable".to_string(),
584                category: CashFlowCategory::Operating,
585                amount: -ar_change,
586                amount_prior: None,
587                sort_order: 3,
588                is_total: false,
589            },
590            CashFlowItem {
591                item_code: "CF-AP".to_string(),
592                label: "Change in Accounts Payable".to_string(),
593                category: CashFlowCategory::Operating,
594                amount: ap_change,
595                amount_prior: None,
596                sort_order: 4,
597                is_total: false,
598            },
599            CashFlowItem {
600                item_code: "CF-INV".to_string(),
601                label: "Change in Inventory".to_string(),
602                category: CashFlowCategory::Operating,
603                amount: -inventory_change,
604                amount_prior: None,
605                sort_order: 5,
606                is_total: false,
607            },
608            CashFlowItem {
609                item_code: "CF-ACR".to_string(),
610                label: "Change in Accrued Liabilities".to_string(),
611                category: CashFlowCategory::Operating,
612                amount: accrual_change,
613                amount_prior: None,
614                sort_order: 6,
615                is_total: false,
616            },
617            CashFlowItem {
618                item_code: "CF-OP".to_string(),
619                label: "Net Cash from Operating Activities".to_string(),
620                category: CashFlowCategory::Operating,
621                amount: operating_cf,
622                amount_prior: None,
623                sort_order: 7,
624                is_total: true,
625            },
626            CashFlowItem {
627                item_code: "CF-CAPEX".to_string(),
628                label: "Capital Expenditures".to_string(),
629                category: CashFlowCategory::Investing,
630                amount: capex,
631                amount_prior: None,
632                sort_order: 8,
633                is_total: false,
634            },
635            CashFlowItem {
636                item_code: "CF-INV-T".to_string(),
637                label: "Net Cash from Investing Activities".to_string(),
638                category: CashFlowCategory::Investing,
639                amount: investing_cf,
640                amount_prior: None,
641                sort_order: 9,
642                is_total: true,
643            },
644            CashFlowItem {
645                item_code: "CF-DEBT".to_string(),
646                label: "Net Borrowings / (Repayments)".to_string(),
647                category: CashFlowCategory::Financing,
648                amount: debt_change,
649                amount_prior: None,
650                sort_order: 10,
651                is_total: false,
652            },
653            CashFlowItem {
654                item_code: "CF-FIN-T".to_string(),
655                label: "Net Cash from Financing Activities".to_string(),
656                category: CashFlowCategory::Financing,
657                amount: financing_cf,
658                amount_prior: None,
659                sort_order: 11,
660                is_total: true,
661            },
662            CashFlowItem {
663                item_code: "CF-NET".to_string(),
664                label: "Net Change in Cash".to_string(),
665                category: CashFlowCategory::Operating,
666                amount: net_change,
667                amount_prior: None,
668                sort_order: 12,
669                is_total: true,
670            },
671        ];
672
673        FinancialStatement {
674            statement_id: self.uuid_factory.next().to_string(),
675            company_code: company_code.to_string(),
676            statement_type: StatementType::CashFlowStatement,
677            basis: StatementBasis::UsGaap,
678            period_start,
679            period_end,
680            fiscal_year,
681            fiscal_period,
682            line_items: Vec::new(),
683            cash_flow_items,
684            currency: currency.to_string(),
685            is_consolidated: false,
686            preparer_id: preparer_id.to_string(),
687        }
688    }
689
690    fn calculate_net_income(&self, tb: &[TrialBalanceEntry]) -> Decimal {
691        let aggregated = self.aggregate_by_category(tb);
692        // Revenue is credit-normal; negate so it is positive before arithmetic.
693        let revenue = -(*aggregated.get("Revenue").unwrap_or(&Decimal::ZERO));
694        let cogs = *aggregated.get("CostOfSales").unwrap_or(&Decimal::ZERO);
695        let opex = *aggregated
696            .get("OperatingExpenses")
697            .unwrap_or(&Decimal::ZERO);
698        let operating_income = revenue - cogs - opex;
699        let tax = operating_income * Decimal::new(21, 2); // 21% statutory rate
700        operating_income - tax
701    }
702
703    fn aggregate_by_category(&self, tb: &[TrialBalanceEntry]) -> HashMap<String, Decimal> {
704        let mut aggregated: HashMap<String, Decimal> = HashMap::new();
705        for entry in tb {
706            let net = entry.debit_balance - entry.credit_balance;
707            *aggregated.entry(entry.category.clone()).or_default() += net;
708        }
709        aggregated
710    }
711}
712
713#[cfg(test)]
714#[allow(clippy::unwrap_used)]
715mod tests {
716    use super::*;
717
718    fn test_trial_balance() -> Vec<TrialBalanceEntry> {
719        vec![
720            TrialBalanceEntry {
721                account_code: "1000".to_string(),
722                account_name: "Cash".to_string(),
723                category: "Cash".to_string(),
724                debit_balance: Decimal::from(500_000),
725                credit_balance: Decimal::ZERO,
726            },
727            TrialBalanceEntry {
728                account_code: "1100".to_string(),
729                account_name: "Accounts Receivable".to_string(),
730                category: "Receivables".to_string(),
731                debit_balance: Decimal::from(200_000),
732                credit_balance: Decimal::ZERO,
733            },
734            TrialBalanceEntry {
735                account_code: "1300".to_string(),
736                account_name: "Inventory".to_string(),
737                category: "Inventory".to_string(),
738                debit_balance: Decimal::from(150_000),
739                credit_balance: Decimal::ZERO,
740            },
741            TrialBalanceEntry {
742                account_code: "1500".to_string(),
743                account_name: "Fixed Assets".to_string(),
744                category: "FixedAssets".to_string(),
745                debit_balance: Decimal::from(800_000),
746                credit_balance: Decimal::ZERO,
747            },
748            TrialBalanceEntry {
749                account_code: "2000".to_string(),
750                account_name: "Accounts Payable".to_string(),
751                category: "Payables".to_string(),
752                debit_balance: Decimal::ZERO,
753                credit_balance: Decimal::from(120_000),
754            },
755            TrialBalanceEntry {
756                account_code: "2100".to_string(),
757                account_name: "Accrued Liabilities".to_string(),
758                category: "AccruedLiabilities".to_string(),
759                debit_balance: Decimal::ZERO,
760                credit_balance: Decimal::from(80_000),
761            },
762            TrialBalanceEntry {
763                account_code: "4000".to_string(),
764                account_name: "Revenue".to_string(),
765                category: "Revenue".to_string(),
766                debit_balance: Decimal::ZERO,
767                credit_balance: Decimal::from(1_000_000),
768            },
769            TrialBalanceEntry {
770                account_code: "5000".to_string(),
771                account_name: "Cost of Goods Sold".to_string(),
772                category: "CostOfSales".to_string(),
773                debit_balance: Decimal::from(600_000),
774                credit_balance: Decimal::ZERO,
775            },
776            TrialBalanceEntry {
777                account_code: "6000".to_string(),
778                account_name: "Operating Expenses".to_string(),
779                category: "OperatingExpenses".to_string(),
780                debit_balance: Decimal::from(250_000),
781                credit_balance: Decimal::ZERO,
782            },
783        ]
784    }
785
786    #[test]
787    fn test_basic_generation() {
788        let mut gen = FinancialStatementGenerator::new(42);
789        let tb = test_trial_balance();
790        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
791        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
792
793        let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
794
795        // Default config generates all 3 statement types
796        assert_eq!(statements.len(), 3);
797
798        let bs = statements
799            .iter()
800            .find(|s| s.statement_type == StatementType::BalanceSheet)
801            .unwrap();
802        let is = statements
803            .iter()
804            .find(|s| s.statement_type == StatementType::IncomeStatement)
805            .unwrap();
806        let cf = statements
807            .iter()
808            .find(|s| s.statement_type == StatementType::CashFlowStatement)
809            .unwrap();
810
811        // Balance sheet checks
812        assert!(!bs.statement_id.is_empty());
813        assert_eq!(bs.company_code, "C001");
814        assert_eq!(bs.currency, "USD");
815        assert!(!bs.line_items.is_empty());
816        assert_eq!(bs.fiscal_year, 2024);
817        assert_eq!(bs.fiscal_period, 1);
818        assert_eq!(bs.preparer_id, "PREP-01");
819
820        // Income statement checks
821        assert!(!is.statement_id.is_empty());
822        assert!(!is.line_items.is_empty());
823
824        // Cash flow checks
825        assert!(!cf.statement_id.is_empty());
826        assert!(!cf.cash_flow_items.is_empty());
827    }
828
829    #[test]
830    fn test_deterministic() {
831        let tb = test_trial_balance();
832        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
833        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
834
835        let mut gen1 = FinancialStatementGenerator::new(42);
836        let mut gen2 = FinancialStatementGenerator::new(42);
837
838        let r1 = gen1.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
839        let r2 = gen2.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
840
841        assert_eq!(r1.len(), r2.len());
842        for (a, b) in r1.iter().zip(r2.iter()) {
843            assert_eq!(a.statement_id, b.statement_id);
844            assert_eq!(a.statement_type, b.statement_type);
845            assert_eq!(a.line_items.len(), b.line_items.len());
846            assert_eq!(a.cash_flow_items.len(), b.cash_flow_items.len());
847
848            for (li_a, li_b) in a.line_items.iter().zip(b.line_items.iter()) {
849                assert_eq!(li_a.line_code, li_b.line_code);
850                assert_eq!(li_a.amount, li_b.amount);
851            }
852            for (cf_a, cf_b) in a.cash_flow_items.iter().zip(b.cash_flow_items.iter()) {
853                assert_eq!(cf_a.item_code, cf_b.item_code);
854                assert_eq!(cf_a.amount, cf_b.amount);
855            }
856        }
857    }
858
859    #[test]
860    fn test_balance_sheet_balances() {
861        let mut gen = FinancialStatementGenerator::new(42);
862        let tb = test_trial_balance();
863        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
864        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
865
866        let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
867        let bs = statements
868            .iter()
869            .find(|s| s.statement_type == StatementType::BalanceSheet)
870            .unwrap();
871
872        // Find Total Assets and Total Liabilities & Equity
873        let total_assets = bs
874            .line_items
875            .iter()
876            .find(|li| li.line_code == "BS-TA")
877            .unwrap();
878        let total_le = bs
879            .line_items
880            .iter()
881            .find(|li| li.line_code == "BS-TLE")
882            .unwrap();
883
884        // Assets = Liabilities + Equity (balance sheet must balance)
885        assert_eq!(
886            total_assets.amount, total_le.amount,
887            "Balance sheet does not balance: Assets={} vs L+E={}",
888            total_assets.amount, total_le.amount
889        );
890    }
891
892    #[test]
893    fn test_income_statement_structure() {
894        let mut gen = FinancialStatementGenerator::new(42);
895        let tb = test_trial_balance();
896        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
897        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
898
899        let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
900        let is = statements
901            .iter()
902            .find(|s| s.statement_type == StatementType::IncomeStatement)
903            .unwrap();
904
905        // Check expected line items exist
906        let codes: Vec<&str> = is
907            .line_items
908            .iter()
909            .map(|li| li.line_code.as_str())
910            .collect();
911        assert!(codes.contains(&"IS-REV"));
912        assert!(codes.contains(&"IS-COGS"));
913        assert!(codes.contains(&"IS-GP"));
914        assert!(codes.contains(&"IS-OPEX"));
915        assert!(codes.contains(&"IS-OI"));
916        assert!(codes.contains(&"IS-TAX"));
917        assert!(codes.contains(&"IS-NI"));
918
919        // Revenue is credit-normal in the TB but is presented as a positive amount on the IS.
920        // The generator negates the TB net (debit - credit) so that revenue displays positively.
921        let revenue = is
922            .line_items
923            .iter()
924            .find(|li| li.line_code == "IS-REV")
925            .unwrap();
926        // TB net = debit(0) - credit(1_000_000) = -1_000_000; after negation = +1_000_000.
927        assert_eq!(revenue.amount, Decimal::from(1_000_000));
928    }
929}