Skip to main content

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 = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
112    pub pre_elimination_total: Decimal,
113    /// Net elimination adjustment (positive = increases total, negative = decreases)
114    #[serde(with = "crate::serde_decimal")]
115    pub elimination_adjustments: Decimal,
116    /// post_elimination_total = pre_elimination_total + elimination_adjustments
117    #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
160    pub revenue_external: Decimal,
161    /// Revenue from transactions with other operating segments (eliminated on consolidation)
162    #[serde(with = "crate::serde_decimal")]
163    pub revenue_intersegment: Decimal,
164    /// Segment operating profit (before corporate overhead and group tax)
165    #[serde(with = "crate::serde_decimal")]
166    pub operating_profit: Decimal,
167    /// Total assets allocated to this segment
168    #[serde(with = "crate::serde_decimal")]
169    pub total_assets: Decimal,
170    /// Total liabilities allocated to this segment
171    #[serde(with = "crate::serde_decimal")]
172    pub total_liabilities: Decimal,
173    /// Capital expenditure (additions to PP&E and intangibles) in the period
174    #[serde(with = "crate::serde_decimal")]
175    pub capital_expenditure: Decimal,
176    /// Depreciation and amortisation charged in the period
177    #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
195    pub segment_revenue_total: Decimal,
196    /// Elimination of intersegment revenues (typically negative)
197    #[serde(with = "crate::serde_decimal")]
198    pub intersegment_eliminations: Decimal,
199    /// Consolidated external revenue = segment_revenue_total + intersegment_eliminations
200    #[serde(with = "crate::serde_decimal")]
201    pub consolidated_revenue: Decimal,
202    /// Sum of all reportable segment operating profits
203    #[serde(with = "crate::serde_decimal")]
204    pub segment_profit_total: Decimal,
205    /// Unallocated corporate overhead (negative amount)
206    #[serde(with = "crate::serde_decimal")]
207    pub corporate_overhead: Decimal,
208    /// Consolidated operating profit = segment_profit_total + corporate_overhead
209    #[serde(with = "crate::serde_decimal")]
210    pub consolidated_profit: Decimal,
211    /// Sum of all reportable segment assets
212    #[serde(with = "crate::serde_decimal")]
213    pub segment_assets_total: Decimal,
214    /// Unallocated corporate / group assets (e.g. deferred tax, goodwill)
215    #[serde(with = "crate::serde_decimal")]
216    pub unallocated_assets: Decimal,
217    /// Consolidated total assets = segment_assets_total + unallocated_assets
218    #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
267    pub actual: Decimal,
268    /// Target value set for the period
269    #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
287    pub budget_amount: Decimal,
288    /// Actual amount recorded for the period
289    #[serde(with = "crate::serde_decimal")]
290    pub actual_amount: Decimal,
291    /// Variance = actual − budget
292    #[serde(with = "crate::serde_decimal")]
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// Dividends
327// ============================================================================
328
329/// A dividend declaration with lifecycle dates and amounts.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct DividendDeclaration {
332    /// Unique identifier
333    pub id: String,
334    /// Company declaring the dividend
335    pub entity_code: String,
336    /// Date the board declared the dividend
337    pub declaration_date: NaiveDate,
338    /// Record date (shareholders of record entitled to dividend)
339    pub record_date: NaiveDate,
340    /// Payment date (cash disbursement)
341    pub payment_date: NaiveDate,
342    /// Dividend per share
343    #[serde(with = "crate::serde_decimal")]
344    pub per_share_amount: Decimal,
345    /// Total dividend amount
346    #[serde(with = "crate::serde_decimal")]
347    pub total_amount: Decimal,
348    /// Currency
349    pub currency: String,
350}
351
352// ---------------------------------------------------------------------------
353// Sankey flow export — multi-step funds-flow view of a statement.
354// ---------------------------------------------------------------------------
355
356/// Which statement a [`SankeyFlow`] represents.
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "snake_case")]
359pub enum SankeyStatementType {
360    /// Income statement (Revenue → Gross Profit → Operating Income → Net Income).
361    IncomeStatement,
362    /// Cash flow statement (Operating / Investing / Financing → Net change in cash).
363    CashFlow,
364}
365
366/// A node in a Sankey flow diagram (d3-sankey / Plotly compatible).
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368pub struct SankeyNode {
369    /// 0-based index into the flow's `nodes` array; referenced by links.
370    pub id: usize,
371    /// Display label (e.g. "Revenue", "Gross Profit").
372    pub label: String,
373    /// Statement section the node belongs to (e.g. "Operating Expenses").
374    pub section: String,
375    /// Node magnitude (band width); always non-negative.
376    #[serde(with = "crate::serde_decimal")]
377    pub value: Decimal,
378    /// Whether the node is a subtotal/aggregate (Gross Profit, Net Income, …).
379    pub is_subtotal: bool,
380    /// Originating statement line code (e.g. "IS-REV"), when applicable.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub line_code: Option<String>,
383}
384
385/// A directed link between two [`SankeyNode`]s.
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387pub struct SankeyLink {
388    /// Source node id.
389    pub source: usize,
390    /// Target node id.
391    pub target: usize,
392    /// Flow magnitude (link width); always non-negative.
393    #[serde(with = "crate::serde_decimal")]
394    pub value: Decimal,
395    /// Human-readable label (e.g. "Cost of Goods Sold").
396    pub label: String,
397    /// Whether the flow is a contra/reduction (COGS, OpEx, tax, cash outflow).
398    pub is_contra: bool,
399}
400
401/// A complete Sankey funds-flow for one statement and one period (or a
402/// full-year / consolidated rollup). Plain serde structs — no runtime dep.
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
404pub struct SankeyFlow {
405    /// Which statement this flow visualises.
406    pub statement_type: SankeyStatementType,
407    /// Fiscal year.
408    pub fiscal_year: u16,
409    /// Fiscal period (0 ⇒ full-year rollup).
410    pub fiscal_period: u8,
411    /// Owning entity code (or the group code for a consolidated flow).
412    pub company_code: String,
413    /// Presentation currency.
414    pub currency: String,
415    /// Whether this is a consolidated flow.
416    pub is_consolidated: bool,
417    /// Period start (earliest period for a rollup).
418    pub period_start: NaiveDate,
419    /// Period end (latest period for a rollup).
420    pub period_end: NaiveDate,
421    /// Flow nodes.
422    pub nodes: Vec<SankeyNode>,
423    /// Flow links.
424    pub links: Vec<SankeyLink>,
425}
426
427// ---------------------------------------------------------------------------
428// Tests — WI-8: FinancialStatementLineItem comparative fields
429// ---------------------------------------------------------------------------
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use rust_decimal_macros::dec;
435
436    fn make_line_item(
437        amount: Decimal,
438        prior_year: Option<Decimal>,
439        assumptions: Option<&str>,
440    ) -> FinancialStatementLineItem {
441        FinancialStatementLineItem {
442            line_code: "BS-CASH".to_string(),
443            label: "Cash and Cash Equivalents".to_string(),
444            section: "Current Assets".to_string(),
445            sort_order: 1,
446            amount,
447            amount_prior: None,
448            prior_year_amount: prior_year,
449            assumptions: assumptions.map(|s| s.to_string()),
450            indent_level: 0,
451            is_total: false,
452            gl_accounts: vec![],
453        }
454    }
455
456    #[test]
457    fn test_prior_year_amount_field_present() {
458        let item = make_line_item(dec!(100_000), Some(dec!(95_000)), None);
459        assert_eq!(item.prior_year_amount, Some(dec!(95_000)));
460    }
461
462    #[test]
463    fn test_assumptions_present_for_estimate_heavy_line() {
464        let assumption_text = "Based on discounted cash flow analysis";
465        let item = make_line_item(dec!(500_000), Some(dec!(480_000)), Some(assumption_text));
466        assert!(item.assumptions.is_some());
467        assert_eq!(item.assumptions.as_deref(), Some(assumption_text));
468    }
469
470    #[test]
471    fn test_prior_year_amounts_are_within_30_pct_of_current() {
472        // Verify that representative prior-year amounts are plausible
473        // (within 30% of the current-year amount, per WI-8 spec).
474        let cases: &[(Decimal, Decimal)] = &[
475            (dec!(100_000), dec!(85_000)),  // -15% — within bounds
476            (dec!(200_000), dec!(230_000)), // +15% — within bounds
477            (dec!(50_000), dec!(35_100)),   // -29.8% — within bounds
478        ];
479        for (current, prior) in cases {
480            let ratio = ((prior - current).abs() / current)
481                .to_string()
482                .parse::<f64>()
483                .unwrap_or(1.0);
484            assert!(
485                ratio <= 0.30,
486                "Prior year amount {prior} is more than 30% away from current {current} \
487                 (ratio={ratio:.3})"
488            );
489        }
490    }
491}