Skip to main content

datasynth_group/aggregate/fs/
notes.rs

1//! Notes to the consolidated financial statements — Task 8.6.
2//!
3//! Emits a basic 8-note set that auditors and statutory preparers
4//! expect alongside the consolidated FS.  Each note is template-
5//! assembled from manifest + coverage + NCI / CTA / equity-method
6//! inputs so two runs with the same inputs produce byte-identical
7//! note bodies (verified by the determinism test).
8//!
9//! # Note set (v5.0)
10//!
11//! 1. **Significant accounting policies** — derived from
12//!    [`AccountingFramework`] and the canonical IFRS / ASC standards
13//!    it points to.
14//! 2. **Basis of consolidation** — names the IFRS 10 / ASC 810
15//!    reference, lists fully-consolidated entities and equity-method
16//!    investees from the manifest.
17//! 3. **IC eliminations summary** — total pairs planned, matched,
18//!    coverage % from the [`CoverageReport`].
19//! 4. **NCI summary** — per-subsidiary opening / closing NCI from
20//!    the rollforwards (IFRS 12.10 disclosure).
21//! 5. **CTA summary** — per-entity period CTA + closing CTA from the
22//!    rollforwards (IAS 21.39 OCI accumulation).
23//! 6. **Operating segments** — placeholder ("deferred to v5.1").
24//! 7. **Subsequent events** — placeholder ("none identified").
25//! 8. **Related parties** — placeholder pointing at the manifest's IC
26//!    relationships (full IAS 24 treatment in v5.1).
27//!
28//! # v5.1 deferrals
29//!
30//! - Per-component segment breakdown (note 6).
31//! - Auto-derived subsequent events from the manifest period (note 7).
32//! - Full IAS 24 related-party disclosure including KMP, transactions
33//!   with affiliates outside the group, etc. (note 8).
34
35use chrono::NaiveDate;
36use datasynth_standards::framework::AccountingFramework;
37use rust_decimal::Decimal;
38use serde::{Deserialize, Serialize};
39
40use crate::aggregate::coverage_report::CoverageReport;
41use crate::aggregate::equity_method::EquityMethodInvestment;
42use crate::aggregate::nci::NciRollforward;
43use crate::aggregate::translation::CtaRollforward;
44use crate::config::ConsolidationMethod;
45use crate::manifest::GroupManifest;
46
47// ── Public types ──────────────────────────────────────────────────────────────
48
49/// Notes to the consolidated financial statements.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct NotesToConsolidatedFs {
52    /// Group identifier.
53    pub group_id: String,
54    /// Reporting date.
55    pub period_end: NaiveDate,
56    /// Accounting framework label (e.g., "IFRS").
57    pub framework: String,
58    /// The note bodies, in note-number order.
59    pub notes: Vec<Note>,
60}
61
62/// One note in the disclosure set.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct Note {
65    /// Sequential note number (1..=N).
66    pub note_number: u32,
67    /// Note title (e.g., "Significant accounting policies").
68    pub title: String,
69    /// Plain-text / lightweight markdown body.
70    pub body: String,
71}
72
73/// Inputs for [`build_notes_to_consolidated_fs`].
74pub struct NotesInputs<'a> {
75    /// Group manifest — used to enumerate entities and pull the
76    /// presentation currency / period.
77    pub manifest: &'a GroupManifest,
78    /// Accounting framework for the standards-policies note.
79    pub framework: AccountingFramework,
80    /// IC matching coverage report — feeds note 3.
81    pub ic_coverage: &'a CoverageReport,
82    /// NCI rollforwards — feed note 4.
83    pub nci_rollforwards: &'a [NciRollforward],
84    /// CTA rollforwards — feed note 5.
85    pub cta_rollforwards: &'a [CtaRollforward],
86    /// Equity-method investments — feed note 2 (basis of consolidation
87    /// list of equity-method investees).
88    pub equity_method_investments: &'a [EquityMethodInvestment],
89}
90
91// ── Public API ────────────────────────────────────────────────────────────────
92
93/// Build the [`NotesToConsolidatedFs`] from `inputs`.
94///
95/// Pure function: every body is template-assembled from the inputs so
96/// two calls with the same inputs produce equal records (and
97/// byte-identical JSON output).
98pub fn build_notes_to_consolidated_fs(
99    inputs: &NotesInputs,
100    period_end: NaiveDate,
101) -> NotesToConsolidatedFs {
102    let mut notes: Vec<Note> = Vec::with_capacity(8);
103
104    // ── Note 1: Significant accounting policies ────────────────────────────
105    notes.push(Note {
106        note_number: 1,
107        title: "Significant accounting policies".to_string(),
108        body: format!(
109            "These consolidated financial statements have been prepared in \
110             accordance with {framework}. Key standards applied: revenue \
111             recognition under {revenue}, lease accounting under {leases}, \
112             fair value measurement under {fair_value}, and impairment \
113             under {impairment}. The financial statements are presented in \
114             {currency}.",
115            framework = inputs.framework,
116            revenue = inputs.framework.revenue_standard(),
117            leases = inputs.framework.lease_standard(),
118            fair_value = inputs.framework.fair_value_standard(),
119            impairment = inputs.framework.impairment_standard(),
120            currency = inputs.manifest.presentation_currency,
121        ),
122    });
123
124    // ── Note 2: Basis of consolidation ─────────────────────────────────────
125    let full_consolidated: Vec<&str> = inputs
126        .manifest
127        .ownership_graph
128        .entities
129        .iter()
130        .filter(|e| {
131            matches!(
132                e.consolidation_method,
133                ConsolidationMethod::Parent | ConsolidationMethod::Full
134            )
135        })
136        .map(|e| e.code.as_str())
137        .collect();
138    let equity_method_entities: Vec<&str> = inputs
139        .manifest
140        .ownership_graph
141        .entities
142        .iter()
143        .filter(|e| e.consolidation_method == ConsolidationMethod::EquityMethod)
144        .map(|e| e.code.as_str())
145        .collect();
146    notes.push(Note {
147        note_number: 2,
148        title: "Basis of consolidation".to_string(),
149        body: format!(
150            "These consolidated financial statements include the parent \
151             entity and all subsidiaries over which the group has control \
152             (IFRS 10 / ASC 810). Fully-consolidated entities ({full_count}): \
153             {full_list}. Equity-method investees ({eq_count}): {eq_list}. \
154             Intercompany transactions and balances have been eliminated on \
155             consolidation.",
156            full_count = full_consolidated.len(),
157            full_list = if full_consolidated.is_empty() {
158                "none".to_string()
159            } else {
160                full_consolidated.join(", ")
161            },
162            eq_count = equity_method_entities.len(),
163            eq_list = if equity_method_entities.is_empty() {
164                "none".to_string()
165            } else {
166                equity_method_entities.join(", ")
167            },
168        ),
169    });
170
171    // ── Note 3: IC eliminations summary ────────────────────────────────────
172    notes.push(Note {
173        note_number: 3,
174        title: "Intercompany eliminations".to_string(),
175        body: format!(
176            "Out of {planned} intercompany transaction pairs planned, \
177             {matched} were matched and eliminated on consolidation \
178             (coverage {coverage:.2}%). Unmatched residuals were retained \
179             with full pair-level diagnostics.",
180            planned = inputs.ic_coverage.total_pairs_planned,
181            matched = inputs.ic_coverage.matched,
182            coverage = inputs.ic_coverage.coverage * 100.0,
183        ),
184    });
185
186    // ── Note 4: Non-controlling interest summary ───────────────────────────
187    let nci_body = if inputs.nci_rollforwards.is_empty() {
188        "The group has no non-controlling interest as of the reporting date.".to_string()
189    } else {
190        let mut lines: Vec<String> = Vec::with_capacity(inputs.nci_rollforwards.len() + 1);
191        lines.push(
192            "Non-controlling interest by subsidiary (opening, share of profit, dividends, closing):"
193                .to_string(),
194        );
195        for rf in inputs.nci_rollforwards {
196            lines.push(format!(
197                "- {entity}: NCI {nci_pct}, opening {opening}, share of profit \
198                 {sop}, dividends {div}, closing {closing} {currency}",
199                entity = rf.entity_code,
200                nci_pct = rf.nci_percent,
201                opening = rf.opening_nci,
202                sop = rf.nci_share_of_profit,
203                div = rf.nci_dividends,
204                closing = rf.closing_nci,
205                currency = rf.currency,
206            ));
207        }
208        lines.join("\n")
209    };
210    notes.push(Note {
211        note_number: 4,
212        title: "Non-controlling interest".to_string(),
213        body: nci_body,
214    });
215
216    // ── Note 5: Currency translation summary ───────────────────────────────
217    let cta_body = if inputs.cta_rollforwards.is_empty() {
218        "All entities report in the group presentation currency; no \
219         translation adjustment was required for the period."
220            .to_string()
221    } else {
222        let mut lines: Vec<String> = Vec::with_capacity(inputs.cta_rollforwards.len() + 1);
223        lines.push(
224            "Cumulative translation adjustment by entity (opening, period, closing):".to_string(),
225        );
226        for rf in inputs.cta_rollforwards {
227            lines.push(format!(
228                "- {entity} ({fc}→{pc}): opening {opening}, period {period}, \
229                 closing {closing}",
230                entity = rf.entity_code,
231                fc = rf.functional_currency,
232                pc = rf.presentation_currency,
233                opening = rf.opening_cta,
234                period = rf.period_cta,
235                closing = rf.closing_cta,
236            ));
237        }
238        lines.join("\n")
239    };
240    notes.push(Note {
241        note_number: 5,
242        title: "Foreign currency translation".to_string(),
243        body: cta_body,
244    });
245
246    // ── Note 6: Operating segments (IFRS 8 / ASC 280 — v5.1) ───────────────
247    notes.push(Note {
248        note_number: 6,
249        title: "Operating segments".to_string(),
250        body: build_operating_segments_note(inputs),
251    });
252
253    // ── Note 7: Subsequent events (placeholder) ────────────────────────────
254    notes.push(Note {
255        note_number: 7,
256        title: "Subsequent events".to_string(),
257        body: "No material subsequent events identified.".to_string(),
258    });
259
260    // ── Note 8: Related parties (placeholder) ──────────────────────────────
261    let _equity_invest_count = inputs.equity_method_investments.len();
262    notes.push(Note {
263        note_number: 8,
264        title: "Related parties".to_string(),
265        body: "Refer to manifest IC relationships for related-party balances.".to_string(),
266    });
267
268    NotesToConsolidatedFs {
269        group_id: inputs.manifest.group_id.clone(),
270        period_end,
271        framework: inputs.framework.to_string(),
272        notes,
273    }
274}
275
276// Avoid an unused-import warning when none of the `_` lookups land —
277// keep `Decimal` and `EquityMethodInvestment` imports referenced via
278// the function body.
279#[allow(dead_code)]
280fn _silence_unused(_: Decimal, _: &EquityMethodInvestment) {}
281
282/// Build the body of Note 6 (Operating segments) from the manifest.
283///
284/// v5.1 implements IFRS 8 / ASC 280 segment disclosure on a
285/// **geographic basis** — entities are grouped by country code, with
286/// per-country headcount and consolidation-method breakdown.  This
287/// honours IFRS 8.13 (entity-wide disclosure of geographic
288/// information) when the operating-segments-by-product-line basis
289/// (IFRS 8.5) isn't yet wired through from per-entity segment data.
290///
291/// Future v5.2+: read the per-entity
292/// `financial_reporting/segment_reporting/segment_reports.json`
293/// archives during aggregate, sum revenue / operating profit / assets
294/// by reportable segment, and emit a full `OperatingSegment` table
295/// alongside this note body.
296fn build_operating_segments_note(inputs: &NotesInputs) -> String {
297    use std::collections::BTreeMap;
298
299    let mut by_country: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
300    for entity in &inputs.manifest.ownership_graph.entities {
301        by_country
302            .entry(entity.country.as_str())
303            .or_default()
304            .push(entity.code.as_str());
305    }
306
307    let total_entities = inputs.manifest.ownership_graph.entities.len();
308    let total_countries = by_country.len();
309
310    let mut body = String::new();
311    body.push_str(&format!(
312        "Geographic segmentation (IFRS 8.13 / ASC 280-10-50-41 \
313         entity-wide disclosure).  The group consolidates {} entities \
314         across {} countries.\n\n",
315        total_entities, total_countries
316    ));
317    body.push_str("Per-country entity breakdown:\n");
318    for (country, entities) in &by_country {
319        body.push_str(&format!(
320            "  - {}: {} entit{} ({})\n",
321            country,
322            entities.len(),
323            if entities.len() == 1 { "y" } else { "ies" },
324            entities.join(", "),
325        ));
326    }
327    body.push('\n');
328    body.push_str(
329        "Operating segments by product line / business unit (IFRS 8.5) \
330         are sourced from per-entity profit-centre hierarchies and \
331         segment_reports artefacts; the consolidated segment \
332         aggregation across entities is on the v5.2 roadmap.  Refer \
333         to each contributing entity's \
334         `financial_reporting/segment_reporting/segment_reports.json` \
335         for the per-entity segment detail.",
336    );
337    body
338}