1use 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
23pub struct FinancialStatementGenerator {
25 #[allow(dead_code)]
27 rng: ChaCha8Rng,
28 uuid_factory: DeterministicUuidFactory,
29 config: FinancialReportingConfig,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TrialBalanceEntry {
35 pub account_code: String,
37 pub account_name: String,
39 pub category: String,
41 pub debit_balance: Decimal,
43 pub credit_balance: Decimal,
45}
46
47impl FinancialStatementGenerator {
48 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 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 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 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 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 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 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 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); 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 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 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 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 let ar_current = get_current("Receivables");
548 let ar_prior = get_prior("Receivables");
549 let ar_change = ar_current - ar_prior; let inv_current = get_current("Inventory");
553 let inv_prior = get_prior("Inventory");
554 let inventory_change = inv_current - inv_prior; let ap_current = get_current("Payables");
558 let ap_prior = get_prior("Payables");
559 let ap_change = ap_current - ap_prior; let accrual_current = get_current("AccruedLiabilities");
563 let accrual_prior = get_prior("AccruedLiabilities");
564 let accrual_change = accrual_current - accrual_prior;
565
566 let operating_cf =
568 net_income + depreciation - ar_change - inventory_change + ap_change + accrual_change;
569
570 let fa_change = fa_current - fa_prior; let capex = -fa_change; let investing_cf = capex;
574
575 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 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); 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 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 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 assert!(!is.statement_id.is_empty());
848 assert!(!is.line_items.is_empty());
849
850 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 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 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 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 let revenue = is
948 .line_items
949 .iter()
950 .find(|li| li.line_code == "IS-REV")
951 .unwrap();
952 assert_eq!(revenue.amount, Decimal::from(1_000_000));
954 }
955}