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