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