datasynth_core/models/financial_statements.rs
1//! Financial statement models for period-end reporting.
2
3use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use uuid::Uuid;
8
9/// Type of financial statement.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum StatementType {
13 /// Balance Sheet (Statement of Financial Position)
14 BalanceSheet,
15 /// Income Statement (Profit & Loss)
16 IncomeStatement,
17 /// Cash Flow Statement
18 CashFlowStatement,
19 /// Statement of Changes in Equity
20 ChangesInEquity,
21}
22
23/// Basis of accounting for the statement.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
25#[serde(rename_all = "snake_case")]
26pub enum StatementBasis {
27 /// US GAAP
28 #[default]
29 UsGaap,
30 /// IFRS
31 Ifrs,
32 /// Statutory/local GAAP
33 Statutory,
34}
35
36/// Cash flow category for cash flow statement items.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum CashFlowCategory {
40 /// Operating activities
41 Operating,
42 /// Investing activities
43 Investing,
44 /// Financing activities
45 Financing,
46}
47
48/// A line item on a financial statement.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct FinancialStatementLineItem {
51 /// Line item code (e.g., "BS-CASH", "IS-REV")
52 pub line_code: String,
53 /// Display label
54 pub label: String,
55 /// Statement section (e.g., "Current Assets", "Revenue")
56 pub section: String,
57 /// Sort order within section
58 pub sort_order: u32,
59 /// Current period amount
60 #[serde(with = "rust_decimal::serde::str")]
61 pub amount: Decimal,
62 /// Prior period amount (for comparison)
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub amount_prior: Option<Decimal>,
65 /// Prior year amount for year-on-year comparative statements
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub prior_year_amount: Option<Decimal>,
68 /// Significant accounting assumptions underlying this line item
69 /// (e.g., goodwill, provisions, fair value measurements)
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub assumptions: Option<String>,
72 /// Indentation level for display hierarchy
73 pub indent_level: u8,
74 /// Whether this is a subtotal/total line
75 pub is_total: bool,
76 /// GL accounts that roll up to this line
77 pub gl_accounts: Vec<String>,
78}
79
80/// A cash flow item (indirect method).
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct CashFlowItem {
83 /// Item code
84 pub item_code: String,
85 /// Display label
86 pub label: String,
87 /// Cash flow category
88 pub category: CashFlowCategory,
89 /// Amount
90 #[serde(with = "rust_decimal::serde::str")]
91 pub amount: Decimal,
92 /// Prior period amount
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub amount_prior: Option<Decimal>,
95 /// Sort order
96 pub sort_order: u32,
97 /// Is this a subtotal line
98 pub is_total: bool,
99}
100
101/// A single line in a consolidation schedule, showing per-entity amounts plus
102/// pre-elimination total, elimination adjustments, and post-elimination total.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ConsolidationLineItem {
105 /// Account category (e.g. "Revenue", "Cash", "Payables")
106 pub account_category: String,
107 /// Per-entity amounts: entity_code → net balance
108 #[serde(default)]
109 pub entity_amounts: HashMap<String, Decimal>,
110 /// Sum of all entity amounts before eliminations
111 #[serde(with = "rust_decimal::serde::str")]
112 pub pre_elimination_total: Decimal,
113 /// Net elimination adjustment (positive = increases total, negative = decreases)
114 #[serde(with = "rust_decimal::serde::str")]
115 pub elimination_adjustments: Decimal,
116 /// post_elimination_total = pre_elimination_total + elimination_adjustments
117 #[serde(with = "rust_decimal::serde::str")]
118 pub post_elimination_total: Decimal,
119}
120
121/// A consolidation schedule showing how individual entity amounts roll up into
122/// the consolidated group total with elimination entries applied.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ConsolidationSchedule {
125 /// Fiscal period label, e.g. "2024-Q1" or "2024-03"
126 pub period: String,
127 /// One line per account category
128 pub line_items: Vec<ConsolidationLineItem>,
129}
130
131// ============================================================================
132// IFRS 8 / ASC 280 — Operating Segment Reporting
133// ============================================================================
134
135/// Basis for how the entity defines its operating segments.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum SegmentType {
139 /// Segments defined by geographic region (country / continent)
140 Geographic,
141 /// Segments defined by product or service line
142 ProductLine,
143 /// Segments that correspond to separate legal entities
144 LegalEntity,
145}
146
147/// A single IFRS 8 / ASC 280 reportable operating segment.
148///
149/// All monetary fields are expressed in the entity's reporting currency.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct OperatingSegment {
152 /// Unique identifier for this segment record (deterministic UUID)
153 pub segment_id: String,
154 /// Human-readable segment name (e.g. "North America", "Software Products")
155 pub name: String,
156 /// Basis on which the segment is identified
157 pub segment_type: SegmentType,
158 /// Revenue from transactions with external customers
159 #[serde(with = "rust_decimal::serde::str")]
160 pub revenue_external: Decimal,
161 /// Revenue from transactions with other operating segments (eliminated on consolidation)
162 #[serde(with = "rust_decimal::serde::str")]
163 pub revenue_intersegment: Decimal,
164 /// Segment operating profit (before corporate overhead and group tax)
165 #[serde(with = "rust_decimal::serde::str")]
166 pub operating_profit: Decimal,
167 /// Total assets allocated to this segment
168 #[serde(with = "rust_decimal::serde::str")]
169 pub total_assets: Decimal,
170 /// Total liabilities allocated to this segment
171 #[serde(with = "rust_decimal::serde::str")]
172 pub total_liabilities: Decimal,
173 /// Capital expenditure (additions to PP&E and intangibles) in the period
174 #[serde(with = "rust_decimal::serde::str")]
175 pub capital_expenditure: Decimal,
176 /// Depreciation and amortisation charged in the period
177 #[serde(with = "rust_decimal::serde::str")]
178 pub depreciation_amortization: Decimal,
179 /// Fiscal period label for which these figures are reported (e.g. "2024-03")
180 pub period: String,
181 /// Company / group these segments belong to
182 pub company_code: String,
183}
184
185/// Reconciliation of segment totals to the consolidated financial statements
186/// as required by IFRS 8 para. 28 and ASC 280-10-50-30.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SegmentReconciliation {
189 /// Fiscal period label (e.g. "2024-03")
190 pub period: String,
191 /// Company / group code
192 pub company_code: String,
193 /// Sum of all reportable segment revenues (external + intersegment)
194 #[serde(with = "rust_decimal::serde::str")]
195 pub segment_revenue_total: Decimal,
196 /// Elimination of intersegment revenues (typically negative)
197 #[serde(with = "rust_decimal::serde::str")]
198 pub intersegment_eliminations: Decimal,
199 /// Consolidated external revenue = segment_revenue_total + intersegment_eliminations
200 #[serde(with = "rust_decimal::serde::str")]
201 pub consolidated_revenue: Decimal,
202 /// Sum of all reportable segment operating profits
203 #[serde(with = "rust_decimal::serde::str")]
204 pub segment_profit_total: Decimal,
205 /// Unallocated corporate overhead (negative amount)
206 #[serde(with = "rust_decimal::serde::str")]
207 pub corporate_overhead: Decimal,
208 /// Consolidated operating profit = segment_profit_total + corporate_overhead
209 #[serde(with = "rust_decimal::serde::str")]
210 pub consolidated_profit: Decimal,
211 /// Sum of all reportable segment assets
212 #[serde(with = "rust_decimal::serde::str")]
213 pub segment_assets_total: Decimal,
214 /// Unallocated corporate / group assets (e.g. deferred tax, goodwill)
215 #[serde(with = "rust_decimal::serde::str")]
216 pub unallocated_assets: Decimal,
217 /// Consolidated total assets = segment_assets_total + unallocated_assets
218 #[serde(with = "rust_decimal::serde::str")]
219 pub consolidated_assets: Decimal,
220}
221
222/// A complete financial statement.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct FinancialStatement {
225 /// Unique statement identifier
226 pub statement_id: String,
227 /// Company code
228 pub company_code: String,
229 /// Statement type
230 pub statement_type: StatementType,
231 /// Accounting basis
232 pub basis: StatementBasis,
233 /// Reporting period start
234 pub period_start: NaiveDate,
235 /// Reporting period end
236 pub period_end: NaiveDate,
237 /// Fiscal year
238 pub fiscal_year: u16,
239 /// Fiscal period
240 pub fiscal_period: u8,
241 /// Line items
242 pub line_items: Vec<FinancialStatementLineItem>,
243 /// Cash flow items (only for CashFlowStatement)
244 pub cash_flow_items: Vec<CashFlowItem>,
245 /// Currency
246 pub currency: String,
247 /// Whether this is a consolidated statement
248 pub is_consolidated: bool,
249 /// Preparer ID
250 pub preparer_id: String,
251}
252
253// ============================================================================
254// Management Report — WI-7
255// ============================================================================
256
257/// A single KPI summary line within a management report.
258///
259/// Captures the actual vs. target comparison and a RAG (Red/Amber/Green) status
260/// for each metric tracked in the period.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct KpiSummaryLine {
263 /// KPI metric name (e.g., "Revenue Growth Rate", "Gross Margin")
264 pub metric: String,
265 /// Actual value achieved in the period
266 #[serde(with = "rust_decimal::serde::str")]
267 pub actual: Decimal,
268 /// Target value set for the period
269 #[serde(with = "rust_decimal::serde::str")]
270 pub target: Decimal,
271 /// Variance as a percentage of target ((actual - target) / target)
272 pub variance_pct: f64,
273 /// Traffic-light status: "green" (< 5% variance), "amber" (< 10%), "red" (>= 10%)
274 pub rag_status: String,
275}
276
277/// A single budget variance line within a management report.
278///
279/// Shows the planned vs. actual for a GL account category and the resulting
280/// variance expressed both in absolute and percentage terms.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct BudgetVarianceLine {
283 /// GL account code or category (e.g., "4000", "Revenue")
284 pub account: String,
285 /// Budgeted amount for the period
286 #[serde(with = "rust_decimal::serde::str")]
287 pub budget_amount: Decimal,
288 /// Actual amount recorded for the period
289 #[serde(with = "rust_decimal::serde::str")]
290 pub actual_amount: Decimal,
291 /// Variance = actual − budget
292 #[serde(with = "rust_decimal::serde::str")]
293 pub variance: Decimal,
294 /// Variance as a percentage of budget ((actual − budget) / budget)
295 pub variance_pct: f64,
296}
297
298/// A management report aggregating KPIs and budget variances for a period.
299///
300/// Management packs and board reports are the primary documents that auditors
301/// reference when performing analytical procedures (ISA 520) and understanding
302/// management's assessment of the business for the period.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct ManagementReport {
305 /// Unique report identifier
306 pub report_id: Uuid,
307 /// Report type: "monthly_pack", "board_report", "forecast", "flash_report"
308 pub report_type: String,
309 /// Fiscal period label (e.g., "2025-Q1", "2025-01")
310 pub period: String,
311 /// Entity this report belongs to
312 pub entity_code: String,
313 /// Employee / role ID of the preparer
314 pub prepared_by: String,
315 /// Date the report was prepared
316 pub prepared_date: NaiveDate,
317 /// KPI summary lines (6–10 metrics)
318 pub kpi_summary: Vec<KpiSummaryLine>,
319 /// Budget variance lines (8–15 accounts)
320 pub budget_variances: Vec<BudgetVarianceLine>,
321 /// Narrative management commentary for the period
322 pub commentary: String,
323}
324
325// ---------------------------------------------------------------------------
326// Tests — WI-8: FinancialStatementLineItem comparative fields
327// ---------------------------------------------------------------------------
328
329#[cfg(test)]
330#[allow(clippy::unwrap_used)]
331mod tests {
332 use super::*;
333 use rust_decimal_macros::dec;
334
335 fn make_line_item(
336 amount: Decimal,
337 prior_year: Option<Decimal>,
338 assumptions: Option<&str>,
339 ) -> FinancialStatementLineItem {
340 FinancialStatementLineItem {
341 line_code: "BS-CASH".to_string(),
342 label: "Cash and Cash Equivalents".to_string(),
343 section: "Current Assets".to_string(),
344 sort_order: 1,
345 amount,
346 amount_prior: None,
347 prior_year_amount: prior_year,
348 assumptions: assumptions.map(|s| s.to_string()),
349 indent_level: 0,
350 is_total: false,
351 gl_accounts: vec![],
352 }
353 }
354
355 #[test]
356 fn test_prior_year_amount_field_present() {
357 let item = make_line_item(dec!(100_000), Some(dec!(95_000)), None);
358 assert_eq!(item.prior_year_amount, Some(dec!(95_000)));
359 }
360
361 #[test]
362 fn test_assumptions_present_for_estimate_heavy_line() {
363 let assumption_text = "Based on discounted cash flow analysis";
364 let item = make_line_item(dec!(500_000), Some(dec!(480_000)), Some(assumption_text));
365 assert!(item.assumptions.is_some());
366 assert_eq!(item.assumptions.as_deref(), Some(assumption_text));
367 }
368
369 #[test]
370 fn test_prior_year_amounts_are_within_30_pct_of_current() {
371 // Verify that representative prior-year amounts are plausible
372 // (within 30% of the current-year amount, per WI-8 spec).
373 let cases: &[(Decimal, Decimal)] = &[
374 (dec!(100_000), dec!(85_000)), // -15% — within bounds
375 (dec!(200_000), dec!(230_000)), // +15% — within bounds
376 (dec!(50_000), dec!(35_100)), // -29.8% — within bounds
377 ];
378 for (current, prior) in cases {
379 let ratio = ((prior - current).abs() / current)
380 .to_string()
381 .parse::<f64>()
382 .unwrap_or(1.0);
383 assert!(
384 ratio <= 0.30,
385 "Prior year amount {prior} is more than 30% away from current {current} \
386 (ratio={ratio:.3})"
387 );
388 }
389 }
390}