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// Enhanced notes context + method
621// ---------------------------------------------------------------------------
622
623/// Extended context carrying data from v2.2/v2.3 modules (manufacturing,
624/// treasury, provisions) used to populate 4 supplementary notes.
625#[derive(Debug, Clone, Default)]
626pub struct EnhancedNotesContext {
627    /// Entity code (matches the primary `NotesGeneratorContext`).
628    pub entity_code: String,
629    /// Fiscal period descriptor (e.g. "FY2024").
630    pub period: String,
631    /// Reporting currency code (e.g. "USD").
632    pub currency: String,
633
634    // ---- Inventory (v2.2 manufacturing) ----
635    /// Carrying value of finished goods inventory.
636    pub finished_goods_value: Decimal,
637    /// Carrying value of work-in-progress inventory.
638    pub wip_value: Decimal,
639    /// Carrying value of raw materials inventory.
640    pub raw_materials_value: Decimal,
641
642    // ---- Debt (v2.3 treasury) ----
643    /// Debt instruments as `(type, principal, maturity_date_str)` tuples.
644    pub debt_instruments: Vec<(String, Decimal, String)>,
645
646    // ---- Hedge accounting (v2.3 treasury) ----
647    /// Total number of hedging relationships.
648    pub hedge_count: usize,
649    /// Number of relationships assessed as effective.
650    pub effective_hedges: usize,
651    /// Aggregate notional amount across all hedges.
652    pub total_notional: Decimal,
653    /// Aggregate fair value (mark-to-market) across all hedges.
654    pub total_fair_value: Decimal,
655
656    // ---- Provisions rollforward (v2.2 warranty + v2.3 ECL) ----
657    /// Provision movements as `(type, opening, additions, closing)` tuples.
658    pub provision_movements: Vec<(String, Decimal, Decimal, Decimal)>,
659}
660
661impl NotesGenerator {
662    /// Generate 4 supplementary notes backed by v2.2/v2.3 data.
663    ///
664    /// Notes are numbered starting from `starting_note_number` so they do not
665    /// collide with the 8 standard notes produced by [`NotesGenerator::generate`].
666    pub fn generate_enhanced_notes(
667        &mut self,
668        context: &EnhancedNotesContext,
669        starting_note_number: u32,
670    ) -> Vec<FinancialStatementNote> {
671        let mut notes = vec![
672            self.note_inventories(context),
673            self.note_borrowings(context),
674            self.note_hedge_accounting(context),
675            self.note_provisions_rollforward(context),
676        ];
677
678        // Assign sequential note numbers starting from the given offset
679        for (i, note) in notes.iter_mut().enumerate() {
680            note.note_number = starting_note_number + i as u32;
681        }
682
683        notes
684    }
685
686    // -----------------------------------------------------------------------
687    // Enhanced note builders
688    // -----------------------------------------------------------------------
689
690    fn note_inventories(&mut self, ctx: &EnhancedNotesContext) -> FinancialStatementNote {
691        let fg = ctx.finished_goods_value;
692        let wip = ctx.wip_value;
693        let rm = ctx.raw_materials_value;
694        let total = fg + wip + rm;
695
696        let narrative = format!(
697            "Inventories are stated at the lower of cost and net realisable value.  \
698             Cost is determined using the weighted-average cost method.  At the end \
699             of {} the carrying amounts were: finished goods {} {fg:.0}, \
700             work in progress {} {wip:.0}, and raw materials {} {rm:.0}, \
701             giving a total of {} {total:.0}.",
702            ctx.period, ctx.currency, ctx.currency, ctx.currency, ctx.currency,
703        );
704
705        let rows = vec![
706            vec![
707                NoteTableValue::Text("Finished Goods".to_string()),
708                NoteTableValue::Amount(fg),
709            ],
710            vec![
711                NoteTableValue::Text("Work in Progress".to_string()),
712                NoteTableValue::Amount(wip),
713            ],
714            vec![
715                NoteTableValue::Text("Raw Materials".to_string()),
716                NoteTableValue::Amount(rm),
717            ],
718            vec![
719                NoteTableValue::Text("Total".to_string()),
720                NoteTableValue::Amount(total),
721            ],
722        ];
723
724        FinancialStatementNote {
725            note_number: 0,
726            title: "Inventories".to_string(),
727            category: NoteCategory::DetailDisclosure,
728            content_sections: vec![NoteSection {
729                heading: "Inventory Breakdown by Category".to_string(),
730                narrative,
731                tables: vec![NoteTable {
732                    caption: format!(
733                        "Inventory Carrying Amounts — {} ({})",
734                        ctx.period, ctx.currency
735                    ),
736                    headers: vec!["Category".to_string(), format!("Amount ({})", ctx.currency)],
737                    rows,
738                }],
739            }],
740            cross_references: Vec::new(),
741        }
742    }
743
744    fn note_borrowings(&mut self, ctx: &EnhancedNotesContext) -> FinancialStatementNote {
745        let total_principal: Decimal = ctx
746            .debt_instruments
747            .iter()
748            .map(|(_, principal, _)| *principal)
749            .sum();
750
751        let narrative = format!(
752            "Borrowings are initially recognised at fair value less directly attributable \
753             transaction costs and subsequently measured at amortised cost.  At {} the \
754             entity had {} debt instrument(s) outstanding with a combined principal of \
755             {} {total_principal:.0}.",
756            ctx.period,
757            ctx.debt_instruments.len(),
758            ctx.currency,
759        );
760
761        let rows: Vec<Vec<NoteTableValue>> = ctx
762            .debt_instruments
763            .iter()
764            .map(|(debt_type, principal, maturity)| {
765                vec![
766                    NoteTableValue::Text(debt_type.clone()),
767                    NoteTableValue::Amount(*principal),
768                    NoteTableValue::Text(maturity.clone()),
769                ]
770            })
771            .collect();
772
773        FinancialStatementNote {
774            note_number: 0,
775            title: "Borrowings and Debt Instruments".to_string(),
776            category: NoteCategory::DetailDisclosure,
777            content_sections: vec![NoteSection {
778                heading: "Debt Maturity Schedule".to_string(),
779                narrative,
780                tables: vec![NoteTable {
781                    caption: format!(
782                        "Debt Instruments Outstanding — {} ({})",
783                        ctx.period, ctx.currency
784                    ),
785                    headers: vec![
786                        "Type".to_string(),
787                        format!("Principal ({})", ctx.currency),
788                        "Maturity Date".to_string(),
789                    ],
790                    rows,
791                }],
792            }],
793            cross_references: Vec::new(),
794        }
795    }
796
797    fn note_hedge_accounting(&mut self, ctx: &EnhancedNotesContext) -> FinancialStatementNote {
798        let effectiveness_rate = if ctx.hedge_count > 0 {
799            Decimal::new(ctx.effective_hedges as i64, 0) / Decimal::new(ctx.hedge_count as i64, 0)
800        } else {
801            Decimal::ZERO
802        };
803
804        let effectiveness_pct = effectiveness_rate * Decimal::new(100, 0);
805
806        let narrative = format!(
807            "The entity applies hedge accounting in accordance with IFRS 9 / ASC 815 \
808             where the hedging relationship meets the qualifying criteria.  At {} \
809             {} hedging relationship(s) were designated, of which {} were assessed as \
810             effective ({effectiveness_pct:.1}%).  The aggregate notional amount was \
811             {} {:.0} with a net fair value of {} {:.0}.",
812            ctx.period,
813            ctx.hedge_count,
814            ctx.effective_hedges,
815            ctx.currency,
816            ctx.total_notional,
817            ctx.currency,
818            ctx.total_fair_value,
819        );
820
821        let kv_pairs = vec![
822            (
823                "Total hedging relationships".to_string(),
824                ctx.hedge_count.to_string(),
825            ),
826            (
827                "Effective hedges".to_string(),
828                ctx.effective_hedges.to_string(),
829            ),
830            (
831                format!("Total notional ({})", ctx.currency),
832                format!("{:.0}", ctx.total_notional),
833            ),
834            (
835                format!("Total fair value ({})", ctx.currency),
836                format!("{:.0}", ctx.total_fair_value),
837            ),
838            (
839                "Effectiveness rate".to_string(),
840                format!("{effectiveness_pct:.1}%"),
841            ),
842        ];
843
844        let rows: Vec<Vec<NoteTableValue>> = kv_pairs
845            .into_iter()
846            .map(|(k, v)| vec![NoteTableValue::Text(k), NoteTableValue::Text(v)])
847            .collect();
848
849        FinancialStatementNote {
850            note_number: 0,
851            title: "Hedge Accounting".to_string(),
852            category: NoteCategory::DetailDisclosure,
853            content_sections: vec![NoteSection {
854                heading: "Hedge Effectiveness and Notional Amounts".to_string(),
855                narrative,
856                tables: vec![NoteTable {
857                    caption: format!("Hedge Accounting Summary — {}", ctx.period),
858                    headers: vec!["Item".to_string(), "Value".to_string()],
859                    rows,
860                }],
861            }],
862            cross_references: Vec::new(),
863        }
864    }
865
866    fn note_provisions_rollforward(
867        &mut self,
868        ctx: &EnhancedNotesContext,
869    ) -> FinancialStatementNote {
870        let total_opening: Decimal = ctx
871            .provision_movements
872            .iter()
873            .map(|(_, opening, _, _)| *opening)
874            .sum();
875        let total_additions: Decimal = ctx
876            .provision_movements
877            .iter()
878            .map(|(_, _, additions, _)| *additions)
879            .sum();
880        let total_closing: Decimal = ctx
881            .provision_movements
882            .iter()
883            .map(|(_, _, _, closing)| *closing)
884            .sum();
885
886        let narrative = format!(
887            "The following table sets out the movement in provisions during {}.  \
888             Provisions are recognised when it is probable that an outflow of economic \
889             resources will be required.  Opening balances totalled {} {total_opening:.0}, \
890             additions during the period were {} {total_additions:.0}, and closing \
891             balances stood at {} {total_closing:.0}.",
892            ctx.period, ctx.currency, ctx.currency, ctx.currency,
893        );
894
895        let mut rows: Vec<Vec<NoteTableValue>> = ctx
896            .provision_movements
897            .iter()
898            .map(|(prov_type, opening, additions, closing)| {
899                vec![
900                    NoteTableValue::Text(prov_type.clone()),
901                    NoteTableValue::Amount(*opening),
902                    NoteTableValue::Amount(*additions),
903                    NoteTableValue::Amount(*closing),
904                ]
905            })
906            .collect();
907
908        // Totals row
909        rows.push(vec![
910            NoteTableValue::Text("Total".to_string()),
911            NoteTableValue::Amount(total_opening),
912            NoteTableValue::Amount(total_additions),
913            NoteTableValue::Amount(total_closing),
914        ]);
915
916        FinancialStatementNote {
917            note_number: 0,
918            title: "Provisions Rollforward".to_string(),
919            category: NoteCategory::DetailDisclosure,
920            content_sections: vec![NoteSection {
921                heading: "Movement in Provisions".to_string(),
922                narrative,
923                tables: vec![NoteTable {
924                    caption: format!("Provisions Rollforward — {} ({})", ctx.period, ctx.currency),
925                    headers: vec![
926                        "Provision Type".to_string(),
927                        format!("Opening ({})", ctx.currency),
928                        format!("Additions ({})", ctx.currency),
929                        format!("Closing ({})", ctx.currency),
930                    ],
931                    rows,
932                }],
933            }],
934            cross_references: vec!["Note 1 — Accounting Policies".to_string()],
935        }
936    }
937}
938
939// ---------------------------------------------------------------------------
940// Unit tests
941// ---------------------------------------------------------------------------
942
943#[cfg(test)]
944#[allow(clippy::unwrap_used)]
945mod tests {
946    use super::*;
947
948    fn default_context() -> NotesGeneratorContext {
949        NotesGeneratorContext {
950            entity_code: "C001".to_string(),
951            framework: "IFRS".to_string(),
952            period: "FY2024".to_string(),
953            period_end: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
954            currency: "USD".to_string(),
955            revenue_contract_count: 50,
956            revenue_amount: Some(Decimal::new(10_000_000, 0)),
957            avg_obligations_per_contract: Some(Decimal::new(2, 0)),
958            total_ppe_gross: Some(Decimal::new(5_000_000, 0)),
959            accumulated_depreciation: Some(Decimal::new(1_500_000, 0)),
960            statutory_tax_rate: Some(Decimal::new(21, 2)),
961            effective_tax_rate: Some(Decimal::new(24, 2)),
962            deferred_tax_asset: Some(Decimal::new(200_000, 0)),
963            deferred_tax_liability: Some(Decimal::new(50_000, 0)),
964            provision_count: 4,
965            total_provisions: Some(Decimal::new(800_000, 0)),
966            related_party_transaction_count: 12,
967            related_party_total_value: Some(Decimal::new(2_500_000, 0)),
968            subsequent_event_count: 3,
969            adjusting_event_count: 1,
970            pension_plan_count: 2,
971            total_dbo: Some(Decimal::new(15_000_000, 0)),
972            total_plan_assets: Some(Decimal::new(13_000_000, 0)),
973        }
974    }
975
976    #[test]
977    fn test_at_least_three_notes_generated() {
978        let mut gen = NotesGenerator::new(42);
979        let ctx = default_context();
980        let notes = gen.generate(&ctx);
981        assert!(
982            notes.len() >= 3,
983            "Expected at least 3 notes, got {}",
984            notes.len()
985        );
986    }
987
988    #[test]
989    fn test_note_numbers_are_sequential() {
990        let mut gen = NotesGenerator::new(42);
991        let ctx = default_context();
992        let notes = gen.generate(&ctx);
993        for (i, note) in notes.iter().enumerate() {
994            assert_eq!(
995                note.note_number,
996                (i + 1) as u32,
997                "Note at index {} has number {}, expected {}",
998                i,
999                note.note_number,
1000                i + 1
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn test_every_note_has_title_and_content() {
1007        let mut gen = NotesGenerator::new(42);
1008        let ctx = default_context();
1009        let notes = gen.generate(&ctx);
1010        for note in &notes {
1011            assert!(
1012                !note.title.is_empty(),
1013                "Note {} has an empty title",
1014                note.note_number
1015            );
1016            assert!(
1017                !note.content_sections.is_empty(),
1018                "Note '{}' has no content sections",
1019                note.title
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn test_accounting_policy_note_always_first() {
1026        let mut gen = NotesGenerator::new(42);
1027        let ctx = default_context();
1028        let notes = gen.generate(&ctx);
1029        assert!(!notes.is_empty());
1030        assert_eq!(notes[0].note_number, 1);
1031        assert!(
1032            notes[0].title.contains("Accounting Policies"),
1033            "First note should be Accounting Policies, got '{}'",
1034            notes[0].title
1035        );
1036    }
1037
1038    #[test]
1039    fn test_no_revenue_note_when_no_revenue_data() {
1040        let mut gen = NotesGenerator::new(42);
1041        let ctx = NotesGeneratorContext {
1042            entity_code: "C001".to_string(),
1043            framework: "US GAAP".to_string(),
1044            period: "FY2024".to_string(),
1045            period_end: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1046            currency: "USD".to_string(),
1047            ..NotesGeneratorContext::default()
1048        };
1049        let notes = gen.generate(&ctx);
1050        // Should still have at least the accounting policies note
1051        assert!(!notes.is_empty());
1052        let has_revenue_note = notes.iter().any(|n| n.title.contains("Revenue"));
1053        assert!(
1054            !has_revenue_note,
1055            "Should not generate revenue note when no data"
1056        );
1057    }
1058
1059    #[test]
1060    fn test_deterministic_output() {
1061        let ctx = default_context();
1062        let notes1 = NotesGenerator::new(42).generate(&ctx);
1063        let notes2 = NotesGenerator::new(42).generate(&ctx);
1064        assert_eq!(notes1.len(), notes2.len());
1065        for (a, b) in notes1.iter().zip(notes2.iter()) {
1066            assert_eq!(a.note_number, b.note_number);
1067            assert_eq!(a.title, b.title);
1068        }
1069    }
1070}