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::prelude::*;
18use rand_chacha::ChaCha8Rng;
19use rust_decimal::Decimal;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use tracing::debug;
23
24pub struct FinancialStatementGenerator {
26 rng: ChaCha8Rng,
27 uuid_factory: DeterministicUuidFactory,
28 config: FinancialReportingConfig,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TrialBalanceEntry {
34 pub account_code: String,
36 pub account_name: String,
38 pub category: String,
40 pub debit_balance: Decimal,
42 pub credit_balance: Decimal,
44}
45
46impl FinancialStatementGenerator {
47 pub fn new(seed: u64) -> Self {
49 Self {
50 rng: seeded_rng(seed, 0),
51 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::FinancialStatement),
52 config: FinancialReportingConfig::default(),
53 }
54 }
55
56 pub fn with_config(seed: u64, config: FinancialReportingConfig) -> Self {
58 Self {
59 rng: seeded_rng(seed, 0),
60 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::FinancialStatement),
61 config,
62 }
63 }
64
65 pub fn generate(
67 &mut self,
68 company_code: &str,
69 currency: &str,
70 trial_balance: &[TrialBalanceEntry],
71 period_start: NaiveDate,
72 period_end: NaiveDate,
73 fiscal_year: u16,
74 fiscal_period: u8,
75 prior_trial_balance: Option<&[TrialBalanceEntry]>,
76 preparer_id: &str,
77 ) -> Vec<FinancialStatement> {
78 debug!(
79 company_code,
80 currency,
81 fiscal_year,
82 fiscal_period,
83 tb_entries = trial_balance.len(),
84 "Generating financial statements"
85 );
86 let mut statements = Vec::new();
87
88 if self.config.generate_balance_sheet {
89 statements.push(self.generate_balance_sheet(
90 company_code,
91 currency,
92 trial_balance,
93 period_start,
94 period_end,
95 fiscal_year,
96 fiscal_period,
97 prior_trial_balance,
98 preparer_id,
99 ));
100 }
101
102 if self.config.generate_income_statement {
103 statements.push(self.generate_income_statement(
104 company_code,
105 currency,
106 trial_balance,
107 period_start,
108 period_end,
109 fiscal_year,
110 fiscal_period,
111 prior_trial_balance,
112 preparer_id,
113 ));
114 }
115
116 if self.config.generate_cash_flow {
117 let net_income = self.calculate_net_income(trial_balance);
118 statements.push(self.generate_cash_flow_statement(
119 company_code,
120 currency,
121 trial_balance,
122 period_start,
123 period_end,
124 fiscal_year,
125 fiscal_period,
126 net_income,
127 preparer_id,
128 ));
129 }
130
131 statements
132 }
133
134 fn generate_balance_sheet(
135 &mut self,
136 company_code: &str,
137 currency: &str,
138 tb: &[TrialBalanceEntry],
139 period_start: NaiveDate,
140 period_end: NaiveDate,
141 fiscal_year: u16,
142 fiscal_period: u8,
143 prior_tb: Option<&[TrialBalanceEntry]>,
144 preparer_id: &str,
145 ) -> FinancialStatement {
146 let mut line_items = Vec::new();
147 let mut sort_order = 0u32;
148
149 let aggregated = self.aggregate_by_category(tb);
151 let prior_aggregated = prior_tb.map(|ptb| self.aggregate_by_category(ptb));
152
153 let get_prior = |key: &str| -> Option<Decimal> {
154 prior_aggregated
155 .as_ref()
156 .and_then(|pa| pa.get(key).copied())
157 };
158
159 let cash = *aggregated.get("Cash").unwrap_or(&Decimal::ZERO);
161 let ar = *aggregated.get("Receivables").unwrap_or(&Decimal::ZERO);
162 let inventory = *aggregated.get("Inventory").unwrap_or(&Decimal::ZERO);
163 let current_assets = cash + ar + inventory;
164 let fixed_assets = *aggregated.get("FixedAssets").unwrap_or(&Decimal::ZERO);
165 let total_assets = current_assets + fixed_assets;
166
167 let items_data = [
168 (
169 "BS-CASH",
170 "Cash and Cash Equivalents",
171 "Current Assets",
172 cash,
173 get_prior("Cash"),
174 0,
175 false,
176 ),
177 (
178 "BS-AR",
179 "Accounts Receivable",
180 "Current Assets",
181 ar,
182 get_prior("Receivables"),
183 0,
184 false,
185 ),
186 (
187 "BS-INV",
188 "Inventory",
189 "Current Assets",
190 inventory,
191 get_prior("Inventory"),
192 0,
193 false,
194 ),
195 (
196 "BS-CA",
197 "Total Current Assets",
198 "Current Assets",
199 current_assets,
200 None,
201 0,
202 true,
203 ),
204 (
205 "BS-FA",
206 "Property, Plant & Equipment, net",
207 "Non-Current Assets",
208 fixed_assets,
209 get_prior("FixedAssets"),
210 0,
211 false,
212 ),
213 (
214 "BS-TA",
215 "Total Assets",
216 "Total Assets",
217 total_assets,
218 None,
219 0,
220 true,
221 ),
222 ];
223
224 for (code, label, section, amount, prior, indent, is_total) in &items_data {
225 sort_order += 1;
226 line_items.push(FinancialStatementLineItem {
227 line_code: code.to_string(),
228 label: label.to_string(),
229 section: section.to_string(),
230 sort_order,
231 amount: *amount,
232 amount_prior: *prior,
233 indent_level: *indent,
234 is_total: *is_total,
235 gl_accounts: Vec::new(),
236 });
237 }
238
239 let ap = *aggregated.get("Payables").unwrap_or(&Decimal::ZERO);
241 let accrued = *aggregated
242 .get("AccruedLiabilities")
243 .unwrap_or(&Decimal::ZERO);
244 let current_liabilities = ap + accrued;
245 let lt_debt = *aggregated.get("LongTermDebt").unwrap_or(&Decimal::ZERO);
246 let total_liabilities = current_liabilities + lt_debt;
247
248 let retained_earnings = total_assets - total_liabilities;
249 let total_equity = retained_earnings;
250 let total_le = total_liabilities + total_equity;
251
252 let le_items = [
253 (
254 "BS-AP",
255 "Accounts Payable",
256 "Current Liabilities",
257 ap,
258 get_prior("Payables"),
259 0,
260 false,
261 ),
262 (
263 "BS-ACR",
264 "Accrued Liabilities",
265 "Current Liabilities",
266 accrued,
267 get_prior("AccruedLiabilities"),
268 0,
269 false,
270 ),
271 (
272 "BS-CL",
273 "Total Current Liabilities",
274 "Current Liabilities",
275 current_liabilities,
276 None,
277 0,
278 true,
279 ),
280 (
281 "BS-LTD",
282 "Long-Term Debt",
283 "Non-Current Liabilities",
284 lt_debt,
285 get_prior("LongTermDebt"),
286 0,
287 false,
288 ),
289 (
290 "BS-TL",
291 "Total Liabilities",
292 "Total Liabilities",
293 total_liabilities,
294 None,
295 0,
296 true,
297 ),
298 (
299 "BS-RE",
300 "Retained Earnings",
301 "Equity",
302 retained_earnings,
303 None,
304 0,
305 false,
306 ),
307 (
308 "BS-TE",
309 "Total Equity",
310 "Equity",
311 total_equity,
312 None,
313 0,
314 true,
315 ),
316 (
317 "BS-TLE",
318 "Total Liabilities & Equity",
319 "Total",
320 total_le,
321 None,
322 0,
323 true,
324 ),
325 ];
326
327 for (code, label, section, amount, prior, indent, is_total) in &le_items {
328 sort_order += 1;
329 line_items.push(FinancialStatementLineItem {
330 line_code: code.to_string(),
331 label: label.to_string(),
332 section: section.to_string(),
333 sort_order,
334 amount: *amount,
335 amount_prior: *prior,
336 indent_level: *indent,
337 is_total: *is_total,
338 gl_accounts: Vec::new(),
339 });
340 }
341
342 FinancialStatement {
343 statement_id: self.uuid_factory.next().to_string(),
344 company_code: company_code.to_string(),
345 statement_type: StatementType::BalanceSheet,
346 basis: StatementBasis::UsGaap,
347 period_start,
348 period_end,
349 fiscal_year,
350 fiscal_period,
351 line_items,
352 cash_flow_items: Vec::new(),
353 currency: currency.to_string(),
354 is_consolidated: false,
355 preparer_id: preparer_id.to_string(),
356 }
357 }
358
359 fn generate_income_statement(
360 &mut self,
361 company_code: &str,
362 currency: &str,
363 tb: &[TrialBalanceEntry],
364 period_start: NaiveDate,
365 period_end: NaiveDate,
366 fiscal_year: u16,
367 fiscal_period: u8,
368 prior_tb: Option<&[TrialBalanceEntry]>,
369 preparer_id: &str,
370 ) -> FinancialStatement {
371 let aggregated = self.aggregate_by_category(tb);
372 let prior_aggregated = prior_tb.map(|ptb| self.aggregate_by_category(ptb));
373
374 let get_prior = |key: &str| -> Option<Decimal> {
375 prior_aggregated
376 .as_ref()
377 .and_then(|pa| pa.get(key).copied())
378 };
379
380 let revenue = *aggregated.get("Revenue").unwrap_or(&Decimal::ZERO);
381 let cogs = *aggregated.get("CostOfSales").unwrap_or(&Decimal::ZERO);
382 let gross_profit = revenue - cogs;
383 let operating_expenses = *aggregated
384 .get("OperatingExpenses")
385 .unwrap_or(&Decimal::ZERO);
386 let operating_income = gross_profit - operating_expenses;
387 let tax = operating_income * Decimal::from_f64_retain(0.25).unwrap_or(Decimal::ZERO);
388 let net_income = operating_income - tax;
389
390 let mut line_items = Vec::new();
391 let items_data = [
392 (
393 "IS-REV",
394 "Revenue",
395 "Revenue",
396 revenue,
397 get_prior("Revenue"),
398 false,
399 ),
400 (
401 "IS-COGS",
402 "Cost of Goods Sold",
403 "Cost of Sales",
404 cogs,
405 get_prior("CostOfSales"),
406 false,
407 ),
408 (
409 "IS-GP",
410 "Gross Profit",
411 "Gross Profit",
412 gross_profit,
413 None,
414 true,
415 ),
416 (
417 "IS-OPEX",
418 "Operating Expenses",
419 "Operating Expenses",
420 operating_expenses,
421 get_prior("OperatingExpenses"),
422 false,
423 ),
424 (
425 "IS-OI",
426 "Operating Income",
427 "Operating Income",
428 operating_income,
429 None,
430 true,
431 ),
432 ("IS-TAX", "Income Tax Expense", "Tax", tax, None, false),
433 ("IS-NI", "Net Income", "Net Income", net_income, None, true),
434 ];
435
436 for (i, (code, label, section, amount, prior, is_total)) in items_data.iter().enumerate() {
437 line_items.push(FinancialStatementLineItem {
438 line_code: code.to_string(),
439 label: label.to_string(),
440 section: section.to_string(),
441 sort_order: (i + 1) as u32,
442 amount: *amount,
443 amount_prior: *prior,
444 indent_level: 0,
445 is_total: *is_total,
446 gl_accounts: Vec::new(),
447 });
448 }
449
450 FinancialStatement {
451 statement_id: self.uuid_factory.next().to_string(),
452 company_code: company_code.to_string(),
453 statement_type: StatementType::IncomeStatement,
454 basis: StatementBasis::UsGaap,
455 period_start,
456 period_end,
457 fiscal_year,
458 fiscal_period,
459 line_items,
460 cash_flow_items: Vec::new(),
461 currency: currency.to_string(),
462 is_consolidated: false,
463 preparer_id: preparer_id.to_string(),
464 }
465 }
466
467 fn generate_cash_flow_statement(
468 &mut self,
469 company_code: &str,
470 currency: &str,
471 _tb: &[TrialBalanceEntry],
472 period_start: NaiveDate,
473 period_end: NaiveDate,
474 fiscal_year: u16,
475 fiscal_period: u8,
476 net_income: Decimal,
477 preparer_id: &str,
478 ) -> FinancialStatement {
479 let depreciation = Decimal::from(self.rng.gen_range(5000..=50000));
481 let ar_change = Decimal::from(self.rng.gen_range(-20000i64..=20000));
482 let ap_change = Decimal::from(self.rng.gen_range(-15000i64..=15000));
483 let inventory_change = Decimal::from(self.rng.gen_range(-10000i64..=10000));
484
485 let operating_cf = net_income + depreciation - ar_change + ap_change - inventory_change;
486
487 let capex = Decimal::from(self.rng.gen_range(-100000i64..=-5000));
488 let investing_cf = capex;
489
490 let debt_change = Decimal::from(self.rng.gen_range(-50000i64..=50000));
491 let financing_cf = debt_change;
492
493 let net_change = operating_cf + investing_cf + financing_cf;
494
495 let cash_flow_items = vec![
496 CashFlowItem {
497 item_code: "CF-NI".to_string(),
498 label: "Net Income".to_string(),
499 category: CashFlowCategory::Operating,
500 amount: net_income,
501 amount_prior: None,
502 sort_order: 1,
503 is_total: false,
504 },
505 CashFlowItem {
506 item_code: "CF-DEP".to_string(),
507 label: "Depreciation & Amortization".to_string(),
508 category: CashFlowCategory::Operating,
509 amount: depreciation,
510 amount_prior: None,
511 sort_order: 2,
512 is_total: false,
513 },
514 CashFlowItem {
515 item_code: "CF-AR".to_string(),
516 label: "Change in Accounts Receivable".to_string(),
517 category: CashFlowCategory::Operating,
518 amount: -ar_change,
519 amount_prior: None,
520 sort_order: 3,
521 is_total: false,
522 },
523 CashFlowItem {
524 item_code: "CF-AP".to_string(),
525 label: "Change in Accounts Payable".to_string(),
526 category: CashFlowCategory::Operating,
527 amount: ap_change,
528 amount_prior: None,
529 sort_order: 4,
530 is_total: false,
531 },
532 CashFlowItem {
533 item_code: "CF-INV".to_string(),
534 label: "Change in Inventory".to_string(),
535 category: CashFlowCategory::Operating,
536 amount: -inventory_change,
537 amount_prior: None,
538 sort_order: 5,
539 is_total: false,
540 },
541 CashFlowItem {
542 item_code: "CF-OP".to_string(),
543 label: "Net Cash from Operating Activities".to_string(),
544 category: CashFlowCategory::Operating,
545 amount: operating_cf,
546 amount_prior: None,
547 sort_order: 6,
548 is_total: true,
549 },
550 CashFlowItem {
551 item_code: "CF-CAPEX".to_string(),
552 label: "Capital Expenditures".to_string(),
553 category: CashFlowCategory::Investing,
554 amount: capex,
555 amount_prior: None,
556 sort_order: 7,
557 is_total: false,
558 },
559 CashFlowItem {
560 item_code: "CF-INV-T".to_string(),
561 label: "Net Cash from Investing Activities".to_string(),
562 category: CashFlowCategory::Investing,
563 amount: investing_cf,
564 amount_prior: None,
565 sort_order: 8,
566 is_total: true,
567 },
568 CashFlowItem {
569 item_code: "CF-DEBT".to_string(),
570 label: "Net Borrowings / (Repayments)".to_string(),
571 category: CashFlowCategory::Financing,
572 amount: debt_change,
573 amount_prior: None,
574 sort_order: 9,
575 is_total: false,
576 },
577 CashFlowItem {
578 item_code: "CF-FIN-T".to_string(),
579 label: "Net Cash from Financing Activities".to_string(),
580 category: CashFlowCategory::Financing,
581 amount: financing_cf,
582 amount_prior: None,
583 sort_order: 10,
584 is_total: true,
585 },
586 CashFlowItem {
587 item_code: "CF-NET".to_string(),
588 label: "Net Change in Cash".to_string(),
589 category: CashFlowCategory::Operating,
590 amount: net_change,
591 amount_prior: None,
592 sort_order: 11,
593 is_total: true,
594 },
595 ];
596
597 FinancialStatement {
598 statement_id: self.uuid_factory.next().to_string(),
599 company_code: company_code.to_string(),
600 statement_type: StatementType::CashFlowStatement,
601 basis: StatementBasis::UsGaap,
602 period_start,
603 period_end,
604 fiscal_year,
605 fiscal_period,
606 line_items: Vec::new(),
607 cash_flow_items,
608 currency: currency.to_string(),
609 is_consolidated: false,
610 preparer_id: preparer_id.to_string(),
611 }
612 }
613
614 fn calculate_net_income(&self, tb: &[TrialBalanceEntry]) -> Decimal {
615 let aggregated = self.aggregate_by_category(tb);
616 let revenue = *aggregated.get("Revenue").unwrap_or(&Decimal::ZERO);
617 let cogs = *aggregated.get("CostOfSales").unwrap_or(&Decimal::ZERO);
618 let opex = *aggregated
619 .get("OperatingExpenses")
620 .unwrap_or(&Decimal::ZERO);
621 let operating_income = revenue - cogs - opex;
622 let tax = operating_income * Decimal::from_f64_retain(0.25).unwrap_or(Decimal::ZERO);
623 operating_income - tax
624 }
625
626 fn aggregate_by_category(&self, tb: &[TrialBalanceEntry]) -> HashMap<String, Decimal> {
627 let mut aggregated: HashMap<String, Decimal> = HashMap::new();
628 for entry in tb {
629 let net = entry.debit_balance - entry.credit_balance;
630 *aggregated.entry(entry.category.clone()).or_default() += net;
631 }
632 aggregated
633 }
634}
635
636#[cfg(test)]
637#[allow(clippy::unwrap_used)]
638mod tests {
639 use super::*;
640
641 fn test_trial_balance() -> Vec<TrialBalanceEntry> {
642 vec![
643 TrialBalanceEntry {
644 account_code: "1000".to_string(),
645 account_name: "Cash".to_string(),
646 category: "Cash".to_string(),
647 debit_balance: Decimal::from(500_000),
648 credit_balance: Decimal::ZERO,
649 },
650 TrialBalanceEntry {
651 account_code: "1100".to_string(),
652 account_name: "Accounts Receivable".to_string(),
653 category: "Receivables".to_string(),
654 debit_balance: Decimal::from(200_000),
655 credit_balance: Decimal::ZERO,
656 },
657 TrialBalanceEntry {
658 account_code: "1300".to_string(),
659 account_name: "Inventory".to_string(),
660 category: "Inventory".to_string(),
661 debit_balance: Decimal::from(150_000),
662 credit_balance: Decimal::ZERO,
663 },
664 TrialBalanceEntry {
665 account_code: "1500".to_string(),
666 account_name: "Fixed Assets".to_string(),
667 category: "FixedAssets".to_string(),
668 debit_balance: Decimal::from(800_000),
669 credit_balance: Decimal::ZERO,
670 },
671 TrialBalanceEntry {
672 account_code: "2000".to_string(),
673 account_name: "Accounts Payable".to_string(),
674 category: "Payables".to_string(),
675 debit_balance: Decimal::ZERO,
676 credit_balance: Decimal::from(120_000),
677 },
678 TrialBalanceEntry {
679 account_code: "2100".to_string(),
680 account_name: "Accrued Liabilities".to_string(),
681 category: "AccruedLiabilities".to_string(),
682 debit_balance: Decimal::ZERO,
683 credit_balance: Decimal::from(80_000),
684 },
685 TrialBalanceEntry {
686 account_code: "4000".to_string(),
687 account_name: "Revenue".to_string(),
688 category: "Revenue".to_string(),
689 debit_balance: Decimal::ZERO,
690 credit_balance: Decimal::from(1_000_000),
691 },
692 TrialBalanceEntry {
693 account_code: "5000".to_string(),
694 account_name: "Cost of Goods Sold".to_string(),
695 category: "CostOfSales".to_string(),
696 debit_balance: Decimal::from(600_000),
697 credit_balance: Decimal::ZERO,
698 },
699 TrialBalanceEntry {
700 account_code: "6000".to_string(),
701 account_name: "Operating Expenses".to_string(),
702 category: "OperatingExpenses".to_string(),
703 debit_balance: Decimal::from(250_000),
704 credit_balance: Decimal::ZERO,
705 },
706 ]
707 }
708
709 #[test]
710 fn test_basic_generation() {
711 let mut gen = FinancialStatementGenerator::new(42);
712 let tb = test_trial_balance();
713 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
714 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
715
716 let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
717
718 assert_eq!(statements.len(), 3);
720
721 let bs = statements
722 .iter()
723 .find(|s| s.statement_type == StatementType::BalanceSheet)
724 .unwrap();
725 let is = statements
726 .iter()
727 .find(|s| s.statement_type == StatementType::IncomeStatement)
728 .unwrap();
729 let cf = statements
730 .iter()
731 .find(|s| s.statement_type == StatementType::CashFlowStatement)
732 .unwrap();
733
734 assert!(!bs.statement_id.is_empty());
736 assert_eq!(bs.company_code, "C001");
737 assert_eq!(bs.currency, "USD");
738 assert!(!bs.line_items.is_empty());
739 assert_eq!(bs.fiscal_year, 2024);
740 assert_eq!(bs.fiscal_period, 1);
741 assert_eq!(bs.preparer_id, "PREP-01");
742
743 assert!(!is.statement_id.is_empty());
745 assert!(!is.line_items.is_empty());
746
747 assert!(!cf.statement_id.is_empty());
749 assert!(!cf.cash_flow_items.is_empty());
750 }
751
752 #[test]
753 fn test_deterministic() {
754 let tb = test_trial_balance();
755 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
756 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
757
758 let mut gen1 = FinancialStatementGenerator::new(42);
759 let mut gen2 = FinancialStatementGenerator::new(42);
760
761 let r1 = gen1.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
762 let r2 = gen2.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
763
764 assert_eq!(r1.len(), r2.len());
765 for (a, b) in r1.iter().zip(r2.iter()) {
766 assert_eq!(a.statement_id, b.statement_id);
767 assert_eq!(a.statement_type, b.statement_type);
768 assert_eq!(a.line_items.len(), b.line_items.len());
769 assert_eq!(a.cash_flow_items.len(), b.cash_flow_items.len());
770
771 for (li_a, li_b) in a.line_items.iter().zip(b.line_items.iter()) {
772 assert_eq!(li_a.line_code, li_b.line_code);
773 assert_eq!(li_a.amount, li_b.amount);
774 }
775 for (cf_a, cf_b) in a.cash_flow_items.iter().zip(b.cash_flow_items.iter()) {
776 assert_eq!(cf_a.item_code, cf_b.item_code);
777 assert_eq!(cf_a.amount, cf_b.amount);
778 }
779 }
780 }
781
782 #[test]
783 fn test_balance_sheet_balances() {
784 let mut gen = FinancialStatementGenerator::new(42);
785 let tb = test_trial_balance();
786 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
787 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
788
789 let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
790 let bs = statements
791 .iter()
792 .find(|s| s.statement_type == StatementType::BalanceSheet)
793 .unwrap();
794
795 let total_assets = bs
797 .line_items
798 .iter()
799 .find(|li| li.line_code == "BS-TA")
800 .unwrap();
801 let total_le = bs
802 .line_items
803 .iter()
804 .find(|li| li.line_code == "BS-TLE")
805 .unwrap();
806
807 assert_eq!(
809 total_assets.amount, total_le.amount,
810 "Balance sheet does not balance: Assets={} vs L+E={}",
811 total_assets.amount, total_le.amount
812 );
813 }
814
815 #[test]
816 fn test_income_statement_structure() {
817 let mut gen = FinancialStatementGenerator::new(42);
818 let tb = test_trial_balance();
819 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
820 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
821
822 let statements = gen.generate("C001", "USD", &tb, start, end, 2024, 1, None, "PREP-01");
823 let is = statements
824 .iter()
825 .find(|s| s.statement_type == StatementType::IncomeStatement)
826 .unwrap();
827
828 let codes: Vec<&str> = is
830 .line_items
831 .iter()
832 .map(|li| li.line_code.as_str())
833 .collect();
834 assert!(codes.contains(&"IS-REV"));
835 assert!(codes.contains(&"IS-COGS"));
836 assert!(codes.contains(&"IS-GP"));
837 assert!(codes.contains(&"IS-OPEX"));
838 assert!(codes.contains(&"IS-OI"));
839 assert!(codes.contains(&"IS-TAX"));
840 assert!(codes.contains(&"IS-NI"));
841
842 let revenue = is
844 .line_items
845 .iter()
846 .find(|li| li.line_code == "IS-REV")
847 .unwrap();
848 assert_eq!(revenue.amount, Decimal::from(-1_000_000));
850 }
851}