1use chrono::NaiveDate;
22use datasynth_core::models::{
23 FinancialStatementNote, NoteCategory, NoteSection, NoteTable, NoteTableValue,
24};
25use datasynth_core::utils::seeded_rng;
26use rand::Rng;
27use rand_chacha::ChaCha8Rng;
28use rust_decimal::Decimal;
29
30#[derive(Debug, Clone, Default)]
38pub struct NotesGeneratorContext {
39 pub entity_code: String,
41 pub framework: String,
43 pub period: String,
45 pub period_end: NaiveDate,
47 pub currency: String,
49
50 pub revenue_contract_count: usize,
53 pub revenue_amount: Option<Decimal>,
55 pub avg_obligations_per_contract: Option<Decimal>,
57
58 pub total_ppe_gross: Option<Decimal>,
61 pub accumulated_depreciation: Option<Decimal>,
63
64 pub statutory_tax_rate: Option<Decimal>,
67 pub effective_tax_rate: Option<Decimal>,
69 pub deferred_tax_asset: Option<Decimal>,
71 pub deferred_tax_liability: Option<Decimal>,
73
74 pub provision_count: usize,
77 pub total_provisions: Option<Decimal>,
79
80 pub related_party_transaction_count: usize,
83 pub related_party_total_value: Option<Decimal>,
85
86 pub subsequent_event_count: usize,
89 pub adjusting_event_count: usize,
91
92 pub pension_plan_count: usize,
95 pub total_dbo: Option<Decimal>,
97 pub total_plan_assets: Option<Decimal>,
99}
100
101pub struct NotesGenerator {
107 rng: ChaCha8Rng,
108}
109
110impl NotesGenerator {
111 pub fn new(seed: u64) -> Self {
113 Self {
114 rng: seeded_rng(seed, 0x4E07), }
116 }
117
118 pub fn generate(&mut self, ctx: &NotesGeneratorContext) -> Vec<FinancialStatementNote> {
124 let mut notes: Vec<FinancialStatementNote> = Vec::new();
125
126 notes.push(self.note_accounting_policies(ctx));
128
129 if ctx.revenue_contract_count > 0 || ctx.revenue_amount.is_some() {
131 notes.push(self.note_revenue_recognition(ctx));
132 }
133
134 if ctx.total_ppe_gross.is_some() {
136 notes.push(self.note_property_plant_equipment(ctx));
137 }
138
139 if ctx.statutory_tax_rate.is_some() || ctx.deferred_tax_asset.is_some() {
141 notes.push(self.note_income_taxes(ctx));
142 }
143
144 if ctx.provision_count > 0 || ctx.total_provisions.is_some() {
146 notes.push(self.note_provisions(ctx));
147 }
148
149 if ctx.related_party_transaction_count > 0 {
151 notes.push(self.note_related_parties(ctx));
152 }
153
154 if ctx.subsequent_event_count > 0 {
156 notes.push(self.note_subsequent_events(ctx));
157 }
158
159 if ctx.pension_plan_count > 0 || ctx.total_dbo.is_some() {
161 notes.push(self.note_employee_benefits(ctx));
162 }
163
164 for (i, note) in notes.iter_mut().enumerate() {
166 note.note_number = (i + 1) as u32;
167 }
168
169 notes
170 }
171
172 fn note_accounting_policies(&mut self, ctx: &NotesGeneratorContext) -> FinancialStatementNote {
177 let framework = &ctx.framework;
178 let narrative = format!(
179 "The financial statements of {} have been prepared in accordance with {} \
180 on a going concern basis, using the historical cost convention except where \
181 otherwise stated. The financial statements are presented in {} and all \
182 values are rounded to the nearest unit unless otherwise indicated. \
183 Critical accounting estimates and judgements are described in the relevant \
184 notes below.",
185 ctx.entity_code, framework, ctx.currency
186 );
187
188 let key_policies = [
189 ("Revenue Recognition", format!("Revenue is recognised in accordance with {} 15 (Revenue from Contracts with Customers). The five-step model is applied to identify contracts, performance obligations, and transaction prices.", if framework.to_lowercase().contains("ifrs") { "IFRS" } else { "ASC 606" })),
190 ("Property, Plant & Equipment", "PP&E is stated at cost less accumulated depreciation and impairment losses. Depreciation is computed on a straight-line basis over the estimated useful lives of the assets.".to_string()),
191 ("Income Taxes", "Income tax expense comprises current and deferred tax. Deferred tax is recognised using the balance sheet liability method.".to_string()),
192 ("Provisions", "A provision is recognised when the entity has a present obligation as a result of a past event, and it is probable that an outflow of resources will be required to settle the obligation.".to_string()),
193 ];
194
195 let table = NoteTable {
196 caption: "Summary of Key Accounting Policies".to_string(),
197 headers: vec![
198 "Policy Area".to_string(),
199 "Accounting Treatment".to_string(),
200 ],
201 rows: key_policies
202 .iter()
203 .map(|(area, treatment)| {
204 vec![
205 NoteTableValue::Text(area.to_string()),
206 NoteTableValue::Text(treatment.clone()),
207 ]
208 })
209 .collect(),
210 };
211
212 FinancialStatementNote {
213 note_number: 0, title: "Significant Accounting Policies".to_string(),
215 category: NoteCategory::AccountingPolicy,
216 content_sections: vec![NoteSection {
217 heading: "Basis of Preparation".to_string(),
218 narrative,
219 tables: vec![table],
220 }],
221 cross_references: Vec::new(),
222 }
223 }
224
225 fn note_revenue_recognition(&mut self, ctx: &NotesGeneratorContext) -> FinancialStatementNote {
226 let contract_count = ctx.revenue_contract_count;
227 let revenue_str = ctx
228 .revenue_amount
229 .map(|a| format!("{} {:.0}", ctx.currency, a))
230 .unwrap_or_else(|| "N/A".to_string());
231 let avg_oblig = ctx
232 .avg_obligations_per_contract
233 .map(|v| format!("{:.1}", v))
234 .unwrap_or_else(|| "N/A".to_string());
235
236 let narrative = format!(
237 "Revenue is recognised when (or as) performance obligations are satisfied by \
238 transferring control of a promised good or service to the customer. During \
239 {} the entity entered into {} revenue contracts with an average of {} \
240 performance obligation(s) per contract. Total revenue recognised was {}.",
241 ctx.period, contract_count, avg_oblig, revenue_str
242 );
243
244 let rows = vec![
245 vec![
246 NoteTableValue::Text("Number of contracts".to_string()),
247 NoteTableValue::Text(contract_count.to_string()),
248 ],
249 vec![
250 NoteTableValue::Text("Revenue recognised".to_string()),
251 NoteTableValue::Text(revenue_str),
252 ],
253 vec![
254 NoteTableValue::Text("Avg. performance obligations per contract".to_string()),
255 NoteTableValue::Text(avg_oblig),
256 ],
257 ];
258
259 FinancialStatementNote {
260 note_number: 0,
261 title: "Revenue Recognition".to_string(),
262 category: NoteCategory::StandardSpecific,
263 content_sections: vec![NoteSection {
264 heading: "Revenue from Contracts with Customers".to_string(),
265 narrative,
266 tables: vec![NoteTable {
267 caption: "Revenue Disaggregation Summary".to_string(),
268 headers: vec!["Metric".to_string(), "Value".to_string()],
269 rows,
270 }],
271 }],
272 cross_references: vec!["Note 1 — Accounting Policies".to_string()],
273 }
274 }
275
276 fn note_property_plant_equipment(
277 &mut self,
278 ctx: &NotesGeneratorContext,
279 ) -> FinancialStatementNote {
280 let gross = ctx.total_ppe_gross.unwrap_or(Decimal::ZERO);
281 let acc_dep = ctx.accumulated_depreciation.unwrap_or(Decimal::ZERO).abs();
282 let net = gross - acc_dep;
283
284 let num_categories = self.rng.random_range(2usize..=4);
286 let category_names = [
287 "Land & Buildings",
288 "Machinery & Equipment",
289 "Motor Vehicles",
290 "IT Equipment & Fixtures",
291 ];
292 let mut rows = Vec::new();
293 for name in category_names.iter().take(num_categories) {
294 let share = Decimal::new(self.rng.random_range(10i64..=40), 2); rows.push(vec![
296 NoteTableValue::Text(name.to_string()),
297 NoteTableValue::Amount(gross * share),
298 NoteTableValue::Amount(acc_dep * share),
299 NoteTableValue::Amount((gross - acc_dep) * share),
300 ]);
301 }
302 rows.push(vec![
304 NoteTableValue::Text("Total".to_string()),
305 NoteTableValue::Amount(gross),
306 NoteTableValue::Amount(acc_dep),
307 NoteTableValue::Amount(net),
308 ]);
309
310 let narrative = format!(
311 "Property, plant and equipment is stated at cost less accumulated depreciation \
312 and any recognised impairment loss. At {} the gross carrying amount was \
313 {currency} {gross:.0} with accumulated depreciation of {currency} {acc_dep:.0}, \
314 resulting in a net book value of {currency} {net:.0}.",
315 ctx.period_end,
316 currency = ctx.currency,
317 );
318
319 FinancialStatementNote {
320 note_number: 0,
321 title: "Property, Plant & Equipment".to_string(),
322 category: NoteCategory::DetailDisclosure,
323 content_sections: vec![NoteSection {
324 heading: "PP&E Roll-Forward".to_string(),
325 narrative,
326 tables: vec![NoteTable {
327 caption: format!("PP&E Carrying Amounts at {}", ctx.period_end),
328 headers: vec![
329 "Category".to_string(),
330 format!("Gross ({currency})", currency = ctx.currency),
331 format!("Acc. Dep. ({currency})", currency = ctx.currency),
332 format!("Net ({currency})", currency = ctx.currency),
333 ],
334 rows,
335 }],
336 }],
337 cross_references: Vec::new(),
338 }
339 }
340
341 fn note_income_taxes(&mut self, ctx: &NotesGeneratorContext) -> FinancialStatementNote {
342 let statutory = ctx
343 .statutory_tax_rate
344 .unwrap_or_else(|| Decimal::new(21, 2)); let effective = ctx.effective_tax_rate.unwrap_or_else(|| {
346 let adj = Decimal::new(self.rng.random_range(-5i64..=5), 2);
347 statutory + adj
348 });
349 let dta = ctx.deferred_tax_asset.unwrap_or(Decimal::ZERO);
350 let dtl = ctx.deferred_tax_liability.unwrap_or(Decimal::ZERO);
351
352 let narrative = format!(
353 "The entity is subject to income taxes in multiple jurisdictions. The statutory \
354 tax rate applicable to the primary jurisdiction is {statutory:.1}%. \
355 The effective tax rate for {period} was {effective:.1}%, reflecting permanent \
356 differences and the utilisation of deferred tax balances. At period end a \
357 deferred tax asset of {currency} {dta:.0} and a deferred tax liability of \
358 {currency} {dtl:.0} were recognised.",
359 statutory = statutory * Decimal::new(100, 0),
360 period = ctx.period,
361 effective = effective * Decimal::new(100, 0),
362 currency = ctx.currency,
363 );
364
365 let rows = vec![
366 vec![
367 NoteTableValue::Text("Statutory tax rate".to_string()),
368 NoteTableValue::Percentage(statutory),
369 ],
370 vec![
371 NoteTableValue::Text("Effective tax rate".to_string()),
372 NoteTableValue::Percentage(effective),
373 ],
374 vec![
375 NoteTableValue::Text("Deferred tax asset".to_string()),
376 NoteTableValue::Amount(dta),
377 ],
378 vec![
379 NoteTableValue::Text("Deferred tax liability".to_string()),
380 NoteTableValue::Amount(dtl),
381 ],
382 vec![
383 NoteTableValue::Text("Net deferred tax position".to_string()),
384 NoteTableValue::Amount(dta - dtl),
385 ],
386 ];
387
388 FinancialStatementNote {
389 note_number: 0,
390 title: "Income Taxes".to_string(),
391 category: NoteCategory::StandardSpecific,
392 content_sections: vec![NoteSection {
393 heading: "Tax Charge and Deferred Tax Balances".to_string(),
394 narrative,
395 tables: vec![NoteTable {
396 caption: "Income Tax Summary".to_string(),
397 headers: vec!["Item".to_string(), "Value".to_string()],
398 rows,
399 }],
400 }],
401 cross_references: Vec::new(),
402 }
403 }
404
405 fn note_provisions(&mut self, ctx: &NotesGeneratorContext) -> FinancialStatementNote {
406 let count = ctx.provision_count;
407 let total = ctx
408 .total_provisions
409 .unwrap_or_else(|| Decimal::new(self.rng.random_range(50_000i64..=5_000_000), 0));
410
411 let narrative = format!(
412 "Provisions are recognised when the entity has a present obligation \
413 (legal or constructive) as a result of a past event, it is probable that \
414 an outflow of resources embodying economic benefits will be required to settle \
415 the obligation, and a reliable estimate can be made of the amount. At {} a \
416 total of {} provision(s) were recognised with a combined carrying value of \
417 {} {:.0}.",
418 ctx.period_end, count, ctx.currency, total
419 );
420
421 let provision_types = [
422 ("Warranty",),
423 ("Legal Claims",),
424 ("Restructuring",),
425 ("Environmental",),
426 ];
427 let num_rows = count.min(provision_types.len()).max(2);
428 let per_provision = if num_rows > 0 {
429 total / Decimal::new(num_rows as i64, 0)
430 } else {
431 total
432 };
433 let mut rows: Vec<Vec<NoteTableValue>> = provision_types[..num_rows]
434 .iter()
435 .map(|(name,)| {
436 vec![
437 NoteTableValue::Text(name.to_string()),
438 NoteTableValue::Amount(per_provision),
439 ]
440 })
441 .collect();
442 rows.push(vec![
443 NoteTableValue::Text("Total".to_string()),
444 NoteTableValue::Amount(total),
445 ]);
446
447 FinancialStatementNote {
448 note_number: 0,
449 title: "Provisions & Contingencies".to_string(),
450 category: NoteCategory::Contingency,
451 content_sections: vec![NoteSection {
452 heading: "Movement in Provisions".to_string(),
453 narrative,
454 tables: vec![NoteTable {
455 caption: format!("Provisions at {} ({})", ctx.period_end, ctx.currency),
456 headers: vec![
457 "Provision Type".to_string(),
458 format!("Carrying Amount ({})", ctx.currency),
459 ],
460 rows,
461 }],
462 }],
463 cross_references: vec!["Note 1 — Accounting Policies".to_string()],
464 }
465 }
466
467 fn note_related_parties(&mut self, ctx: &NotesGeneratorContext) -> FinancialStatementNote {
468 let count = ctx.related_party_transaction_count;
469 let total = ctx
470 .related_party_total_value
471 .unwrap_or_else(|| Decimal::new(self.rng.random_range(100_000i64..=10_000_000), 0));
472
473 let narrative = format!(
474 "During {} the entity engaged in {} related party transaction(s) with a \
475 combined value of {} {:.0}. All transactions were conducted on an arm's-length \
476 basis and have been approved by the board of directors.",
477 ctx.period, count, ctx.currency, total
478 );
479
480 let rows = vec![
481 vec![
482 NoteTableValue::Text("Number of transactions".to_string()),
483 NoteTableValue::Text(count.to_string()),
484 ],
485 vec![
486 NoteTableValue::Text("Total transaction value".to_string()),
487 NoteTableValue::Amount(total),
488 ],
489 ];
490
491 FinancialStatementNote {
492 note_number: 0,
493 title: "Related Party Transactions".to_string(),
494 category: NoteCategory::RelatedParty,
495 content_sections: vec![NoteSection {
496 heading: "Transactions with Related Parties".to_string(),
497 narrative,
498 tables: vec![NoteTable {
499 caption: "Related Party Summary".to_string(),
500 headers: vec!["Item".to_string(), "Value".to_string()],
501 rows,
502 }],
503 }],
504 cross_references: Vec::new(),
505 }
506 }
507
508 fn note_subsequent_events(&mut self, ctx: &NotesGeneratorContext) -> FinancialStatementNote {
509 let count = ctx.subsequent_event_count;
510 let adj = ctx.adjusting_event_count;
511 let non_adj = count.saturating_sub(adj);
512
513 let narrative = format!(
514 "Management has evaluated events and transactions that occurred after the \
515 balance sheet date of {} through the financial statement issuance date. \
516 {} event(s) were identified: {} adjusting event(s) and {} non-adjusting \
517 event(s). Non-adjusting events are disclosed but do not result in \
518 adjustments to the financial statements.",
519 ctx.period_end, count, adj, non_adj
520 );
521
522 let rows = vec![
523 vec![
524 NoteTableValue::Text("Total subsequent events".to_string()),
525 NoteTableValue::Text(count.to_string()),
526 ],
527 vec![
528 NoteTableValue::Text("Adjusting (IAS 10.8 / ASC 855)".to_string()),
529 NoteTableValue::Text(adj.to_string()),
530 ],
531 vec![
532 NoteTableValue::Text("Non-adjusting — disclosed only".to_string()),
533 NoteTableValue::Text(non_adj.to_string()),
534 ],
535 ];
536
537 FinancialStatementNote {
538 note_number: 0,
539 title: "Subsequent Events".to_string(),
540 category: NoteCategory::SubsequentEvent,
541 content_sections: vec![NoteSection {
542 heading: "Events after the Reporting Period".to_string(),
543 narrative,
544 tables: vec![NoteTable {
545 caption: "Subsequent Events Summary".to_string(),
546 headers: vec!["Category".to_string(), "Count".to_string()],
547 rows,
548 }],
549 }],
550 cross_references: Vec::new(),
551 }
552 }
553
554 fn note_employee_benefits(&mut self, ctx: &NotesGeneratorContext) -> FinancialStatementNote {
555 let plan_count = ctx.pension_plan_count;
556 let dbo = ctx
557 .total_dbo
558 .unwrap_or_else(|| Decimal::new(self.rng.random_range(500_000i64..=50_000_000), 0));
559 let assets = ctx
560 .total_plan_assets
561 .unwrap_or_else(|| dbo * Decimal::new(85, 2)); let funded_status = assets - dbo;
563
564 let narrative = format!(
565 "The entity operates {} defined benefit pension plan(s) for qualifying employees. \
566 The defined benefit obligation (DBO) is measured using the Projected Unit Credit \
567 method. At {} the DBO totalled {} {:.0}, while plan assets at fair value \
568 amounted to {} {:.0}, resulting in a net funded status of {} {:.0}.",
569 plan_count,
570 ctx.period_end,
571 ctx.currency,
572 dbo,
573 ctx.currency,
574 assets,
575 ctx.currency,
576 funded_status
577 );
578
579 let rows = vec![
580 vec![
581 NoteTableValue::Text("Number of defined benefit plans".to_string()),
582 NoteTableValue::Text(plan_count.to_string()),
583 ],
584 vec![
585 NoteTableValue::Text("Defined Benefit Obligation (DBO)".to_string()),
586 NoteTableValue::Amount(dbo),
587 ],
588 vec![
589 NoteTableValue::Text("Plan assets at fair value".to_string()),
590 NoteTableValue::Amount(assets),
591 ],
592 vec![
593 NoteTableValue::Text("Net funded status".to_string()),
594 NoteTableValue::Amount(funded_status),
595 ],
596 ];
597
598 FinancialStatementNote {
599 note_number: 0,
600 title: "Employee Benefits".to_string(),
601 category: NoteCategory::StandardSpecific,
602 content_sections: vec![NoteSection {
603 heading: "Defined Benefit Pension Plans".to_string(),
604 narrative,
605 tables: vec![NoteTable {
606 caption: format!(
607 "Pension Plan Summary at {} ({})",
608 ctx.period_end, ctx.currency
609 ),
610 headers: vec!["Item".to_string(), "Value".to_string()],
611 rows,
612 }],
613 }],
614 cross_references: vec!["Note 1 — Accounting Policies".to_string()],
615 }
616 }
617}
618
619#[cfg(test)]
624#[allow(clippy::unwrap_used)]
625mod tests {
626 use super::*;
627
628 fn default_context() -> NotesGeneratorContext {
629 NotesGeneratorContext {
630 entity_code: "C001".to_string(),
631 framework: "IFRS".to_string(),
632 period: "FY2024".to_string(),
633 period_end: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
634 currency: "USD".to_string(),
635 revenue_contract_count: 50,
636 revenue_amount: Some(Decimal::new(10_000_000, 0)),
637 avg_obligations_per_contract: Some(Decimal::new(2, 0)),
638 total_ppe_gross: Some(Decimal::new(5_000_000, 0)),
639 accumulated_depreciation: Some(Decimal::new(1_500_000, 0)),
640 statutory_tax_rate: Some(Decimal::new(21, 2)),
641 effective_tax_rate: Some(Decimal::new(24, 2)),
642 deferred_tax_asset: Some(Decimal::new(200_000, 0)),
643 deferred_tax_liability: Some(Decimal::new(50_000, 0)),
644 provision_count: 4,
645 total_provisions: Some(Decimal::new(800_000, 0)),
646 related_party_transaction_count: 12,
647 related_party_total_value: Some(Decimal::new(2_500_000, 0)),
648 subsequent_event_count: 3,
649 adjusting_event_count: 1,
650 pension_plan_count: 2,
651 total_dbo: Some(Decimal::new(15_000_000, 0)),
652 total_plan_assets: Some(Decimal::new(13_000_000, 0)),
653 }
654 }
655
656 #[test]
657 fn test_at_least_three_notes_generated() {
658 let mut gen = NotesGenerator::new(42);
659 let ctx = default_context();
660 let notes = gen.generate(&ctx);
661 assert!(
662 notes.len() >= 3,
663 "Expected at least 3 notes, got {}",
664 notes.len()
665 );
666 }
667
668 #[test]
669 fn test_note_numbers_are_sequential() {
670 let mut gen = NotesGenerator::new(42);
671 let ctx = default_context();
672 let notes = gen.generate(&ctx);
673 for (i, note) in notes.iter().enumerate() {
674 assert_eq!(
675 note.note_number,
676 (i + 1) as u32,
677 "Note at index {} has number {}, expected {}",
678 i,
679 note.note_number,
680 i + 1
681 );
682 }
683 }
684
685 #[test]
686 fn test_every_note_has_title_and_content() {
687 let mut gen = NotesGenerator::new(42);
688 let ctx = default_context();
689 let notes = gen.generate(&ctx);
690 for note in ¬es {
691 assert!(
692 !note.title.is_empty(),
693 "Note {} has an empty title",
694 note.note_number
695 );
696 assert!(
697 !note.content_sections.is_empty(),
698 "Note '{}' has no content sections",
699 note.title
700 );
701 }
702 }
703
704 #[test]
705 fn test_accounting_policy_note_always_first() {
706 let mut gen = NotesGenerator::new(42);
707 let ctx = default_context();
708 let notes = gen.generate(&ctx);
709 assert!(!notes.is_empty());
710 assert_eq!(notes[0].note_number, 1);
711 assert!(
712 notes[0].title.contains("Accounting Policies"),
713 "First note should be Accounting Policies, got '{}'",
714 notes[0].title
715 );
716 }
717
718 #[test]
719 fn test_no_revenue_note_when_no_revenue_data() {
720 let mut gen = NotesGenerator::new(42);
721 let ctx = NotesGeneratorContext {
722 entity_code: "C001".to_string(),
723 framework: "US GAAP".to_string(),
724 period: "FY2024".to_string(),
725 period_end: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
726 currency: "USD".to_string(),
727 ..NotesGeneratorContext::default()
728 };
729 let notes = gen.generate(&ctx);
730 assert!(!notes.is_empty());
732 let has_revenue_note = notes.iter().any(|n| n.title.contains("Revenue"));
733 assert!(
734 !has_revenue_note,
735 "Should not generate revenue note when no data"
736 );
737 }
738
739 #[test]
740 fn test_deterministic_output() {
741 let ctx = default_context();
742 let notes1 = NotesGenerator::new(42).generate(&ctx);
743 let notes2 = NotesGenerator::new(42).generate(&ctx);
744 assert_eq!(notes1.len(), notes2.len());
745 for (a, b) in notes1.iter().zip(notes2.iter()) {
746 assert_eq!(a.note_number, b.note_number);
747 assert_eq!(a.title, b.title);
748 }
749 }
750}