Skip to main content

datasynth_generators/period_close/
notes_generator.rs

1//! Notes to financial statements generator.
2//!
3//! Assembles 8–13 standard notes from available generation outputs.
4//! Each note is template-driven: if the underlying data is not present,
5//! that note is simply omitted.  The notes produced follow the ordering
6//! and content requirements of IAS 1 Presentation of Financial Statements
7//! and, where applicable, ASC 235 / the relevant topic-specific US GAAP
8//! guidance.
9//!
10//! # Notes generated (when data is available)
11//!
12//! 1. Significant Accounting Policies
13//! 2. Revenue Recognition (ASC 606 / IFRS 15)
14//! 3. Property, Plant & Equipment
15//! 4. Income Taxes (IAS 12 / ASC 740)
16//! 5. Provisions & Contingencies (IAS 37 / ASC 450)
17//! 6. Related Party Transactions (IAS 24 / ASC 850)
18//! 7. Subsequent Events (IAS 10 / ASC 855)
19//! 8. Employee Benefits / Pensions (IAS 19 / ASC 715)
20
21use 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// ---------------------------------------------------------------------------
31// Input context
32// ---------------------------------------------------------------------------
33
34/// Summary data drawn from the overall generation result, passed into the
35/// notes generator so it can decide which notes to include and what numbers
36/// to populate them with.
37#[derive(Debug, Clone, Default)]
38pub struct NotesGeneratorContext {
39    /// Entity / company code for which notes are prepared.
40    pub entity_code: String,
41    /// Reporting framework name (e.g. "IFRS", "US GAAP").
42    pub framework: String,
43    /// Fiscal period descriptor (e.g. "FY2024").
44    pub period: String,
45    /// Period end date.
46    pub period_end: NaiveDate,
47    /// Reporting currency code (e.g. "USD").
48    pub currency: String,
49
50    // ---- Revenue ----
51    /// Number of customer contracts recognised during the period.
52    pub revenue_contract_count: usize,
53    /// Total revenue amount.
54    pub revenue_amount: Option<Decimal>,
55    /// Average number of performance obligations per contract.
56    pub avg_obligations_per_contract: Option<Decimal>,
57
58    // ---- PP&E ----
59    /// Total gross fixed asset carrying amount.
60    pub total_ppe_gross: Option<Decimal>,
61    /// Accumulated depreciation on fixed assets.
62    pub accumulated_depreciation: Option<Decimal>,
63
64    // ---- Taxes ----
65    /// Statutory tax rate (e.g. 0.21 for 21 %).
66    pub statutory_tax_rate: Option<Decimal>,
67    /// Effective tax rate actually incurred.
68    pub effective_tax_rate: Option<Decimal>,
69    /// Deferred tax asset balance.
70    pub deferred_tax_asset: Option<Decimal>,
71    /// Deferred tax liability balance.
72    pub deferred_tax_liability: Option<Decimal>,
73
74    // ---- Provisions ----
75    /// Number of provisions recognised.
76    pub provision_count: usize,
77    /// Total carrying value of all provisions.
78    pub total_provisions: Option<Decimal>,
79
80    // ---- Related parties ----
81    /// Number of related party transactions identified.
82    pub related_party_transaction_count: usize,
83    /// Total value of related party transactions.
84    pub related_party_total_value: Option<Decimal>,
85
86    // ---- Subsequent events ----
87    /// Number of subsequent events identified.
88    pub subsequent_event_count: usize,
89    /// Number of adjusting subsequent events.
90    pub adjusting_event_count: usize,
91
92    // ---- Pensions ----
93    /// Number of defined benefit plans.
94    pub pension_plan_count: usize,
95    /// Total DBO at period end.
96    pub total_dbo: Option<Decimal>,
97    /// Total plan assets at fair value.
98    pub total_plan_assets: Option<Decimal>,
99}
100
101// ---------------------------------------------------------------------------
102// Generator
103// ---------------------------------------------------------------------------
104
105/// Generator for notes to the financial statements.
106pub struct NotesGenerator {
107    rng: ChaCha8Rng,
108}
109
110impl NotesGenerator {
111    /// Create a new generator with the given seed.
112    pub fn new(seed: u64) -> Self {
113        Self {
114            rng: seeded_rng(seed, 0x4E07), // discriminator for "NOTE"
115        }
116    }
117
118    /// Generate all applicable notes for a single entity/period.
119    ///
120    /// Returns between 0 and 8 notes depending on which data is present in
121    /// the provided context.  Note numbers are assigned sequentially starting
122    /// from 1.
123    pub fn generate(&mut self, ctx: &NotesGeneratorContext) -> Vec<FinancialStatementNote> {
124        let mut notes: Vec<FinancialStatementNote> = Vec::new();
125
126        // Note 1 — Significant Accounting Policies (always generated)
127        notes.push(self.note_accounting_policies(ctx));
128
129        // Note 2 — Revenue Recognition
130        if ctx.revenue_contract_count > 0 || ctx.revenue_amount.is_some() {
131            notes.push(self.note_revenue_recognition(ctx));
132        }
133
134        // Note 3 — Property, Plant & Equipment
135        if ctx.total_ppe_gross.is_some() {
136            notes.push(self.note_property_plant_equipment(ctx));
137        }
138
139        // Note 4 — Income Taxes
140        if ctx.statutory_tax_rate.is_some() || ctx.deferred_tax_asset.is_some() {
141            notes.push(self.note_income_taxes(ctx));
142        }
143
144        // Note 5 — Provisions & Contingencies
145        if ctx.provision_count > 0 || ctx.total_provisions.is_some() {
146            notes.push(self.note_provisions(ctx));
147        }
148
149        // Note 6 — Related Party Transactions
150        if ctx.related_party_transaction_count > 0 {
151            notes.push(self.note_related_parties(ctx));
152        }
153
154        // Note 7 — Subsequent Events
155        if ctx.subsequent_event_count > 0 {
156            notes.push(self.note_subsequent_events(ctx));
157        }
158
159        // Note 8 — Employee Benefits (Pensions)
160        if ctx.pension_plan_count > 0 || ctx.total_dbo.is_some() {
161            notes.push(self.note_employee_benefits(ctx));
162        }
163
164        // Assign sequential note numbers
165        for (i, note) in notes.iter_mut().enumerate() {
166            note.note_number = (i + 1) as u32;
167        }
168
169        notes
170    }
171
172    // -----------------------------------------------------------------------
173    // Note builders
174    // -----------------------------------------------------------------------
175
176    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, // renumbered later
214            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        // Generate 2–4 asset category rows
285        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); // 0.10–0.40
295            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        // Totals row
303        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)); // default 21%
345        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)); // funded at ~85%
562        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// ---------------------------------------------------------------------------
620// Unit tests
621// ---------------------------------------------------------------------------
622
623#[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 &notes {
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        // Should still have at least the accounting policies note
731        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}