datasynth_group/aggregate/fs/
notes.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct NotesToConsolidatedFs {
52 pub group_id: String,
54 pub period_end: NaiveDate,
56 pub framework: String,
58 pub notes: Vec<Note>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct Note {
65 pub note_number: u32,
67 pub title: String,
69 pub body: String,
71}
72
73pub struct NotesInputs<'a> {
75 pub manifest: &'a GroupManifest,
78 pub framework: AccountingFramework,
80 pub ic_coverage: &'a CoverageReport,
82 pub nci_rollforwards: &'a [NciRollforward],
84 pub cta_rollforwards: &'a [CtaRollforward],
86 pub equity_method_investments: &'a [EquityMethodInvestment],
89}
90
91pub 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 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 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 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 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 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 notes.push(Note {
248 note_number: 6,
249 title: "Operating segments".to_string(),
250 body: build_operating_segments_note(inputs),
251 });
252
253 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 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#[allow(dead_code)]
280fn _silence_unused(_: Decimal, _: &EquityMethodInvestment) {}
281
282fn 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}