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 });
239 }
240
241 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 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 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); 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 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 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 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 let ar_current = get_current("Receivables");
522 let ar_prior = get_prior("Receivables");
523 let ar_change = ar_current - ar_prior; let inv_current = get_current("Inventory");
527 let inv_prior = get_prior("Inventory");
528 let inventory_change = inv_current - inv_prior; let ap_current = get_current("Payables");
532 let ap_prior = get_prior("Payables");
533 let ap_change = ap_current - ap_prior; let accrual_current = get_current("AccruedLiabilities");
537 let accrual_prior = get_prior("AccruedLiabilities");
538 let accrual_change = accrual_current - accrual_prior;
539
540 let operating_cf =
542 net_income + depreciation - ar_change - inventory_change + ap_change + accrual_change;
543
544 let fa_change = fa_current - fa_prior; let capex = -fa_change; let investing_cf = capex;
548
549 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 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); 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 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 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 assert!(!is.statement_id.is_empty());
822 assert!(!is.line_items.is_empty());
823
824 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 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 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 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 let revenue = is
922 .line_items
923 .iter()
924 .find(|li| li.line_code == "IS-REV")
925 .unwrap();
926 assert_eq!(revenue.amount, Decimal::from(1_000_000));
928 }
929}