Skip to main content

datasynth_core/models/
hyperinflation.rs

1//! Hyperinflationary-economy accounting under IAS 29 / ASC 830.
2//!
3//! When an entity's functional currency is the currency of a
4//! hyperinflationary economy, IAS 29 requires the entity to restate
5//! its non-monetary items using a general price index so the
6//! financial statements are stated in terms of the **measuring unit
7//! current at the end of the reporting period**.  v5.0 / v5.1 did not
8//! handle this — every entity went through the standard IAS 21
9//! translation path which produces nonsense results once cumulative
10//! inflation crosses ~100 %.
11//!
12//! v5.2 ships the typed model + arithmetic helpers; the integration
13//! with the IAS 21 translation pipeline (`translate_entity_tb` /
14//! `cta_rollforward`) is a follow-up.
15//!
16//! # Standards reference
17//!
18//! - **IAS 29 § 3** — characteristics of a hyperinflationary economy
19//!   (cumulative 3-year inflation approaching or exceeding 100 %; the
20//!   general population prefers a stable foreign currency to keep its
21//!   wealth; etc.).  IAS 29 does not establish an absolute rate at
22//!   which hyperinflation is deemed to arise — it's a matter of
23//!   judgement.
24//! - **IAS 29 § 8** — the financial statements **shall be stated in
25//!   terms of the measuring unit current at the end of the reporting
26//!   period**.  Comparative figures are also restated.
27//! - **IAS 29 § 12** — non-monetary items carried at historical cost
28//!   are restated by applying the change in the general price index
29//!   between the date of acquisition (or revaluation) and the
30//!   reporting date.
31//! - **IAS 29 § 13** — non-monetary items at current value (e.g.
32//!   inventories at NRV) are NOT restated (already at current
33//!   measuring unit).
34//! - **IAS 29 § 27** — the **net gain or loss on the net monetary
35//!   position** is included in profit or loss for the period.  It
36//!   represents the loss of purchasing power on monetary items
37//!   (cash, receivables, payables).
38//! - **IAS 21 § 39 / IAS 29 § 33** — when restated financial
39//!   statements of a hyperinflationary subsidiary are translated
40//!   into the group's (non-hyperinflationary) presentation
41//!   currency, the **closing rate** is used for ALL items (not the
42//!   spot/average split that IAS 21 normally prescribes).
43//!
44//! # Scope
45//!
46//! v5.2 ships:
47//!
48//! - [`HyperinflationStatus`] entity-level flag
49//! - [`GeneralPriceIndex`] CPI series + index lookup
50//! - [`IndexedRestatement`] line-item restatement record
51//! - [`NetMonetaryPositionGainLoss`] helper for the IAS 29 § 27
52//!   purchasing-power gain/loss calc
53//!
54//! The wiring to actually drive these through `translate_entity_tb`
55//! is a follow-up tracked in the README.
56
57use chrono::NaiveDate;
58use rust_decimal::Decimal;
59use serde::{Deserialize, Serialize};
60
61/// Hyperinflation status of an entity's functional currency.
62/// Captured per-entity per-period because a country can transition
63/// in or out of hyperinflation across reporting cycles (e.g.
64/// Argentina entered hyperinflationary status in 2018 per IAS 29
65/// criteria; Türkiye did so in 2022).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum HyperinflationStatus {
69    /// The functional currency is **not** hyperinflationary.
70    /// Standard IAS 21 translation applies.
71    #[default]
72    NotHyperinflationary,
73    /// The functional currency **is** hyperinflationary; IAS 29
74    /// restatement applies before IAS 21 translation per IAS 21 § 43.
75    /// The closing rate is used for all items per IAS 21 § 42(b).
76    Hyperinflationary,
77}
78
79impl HyperinflationStatus {
80    /// Returns `true` when this status requires IAS 29 restatement
81    /// before IAS 21 translation.
82    pub fn requires_restatement(&self) -> bool {
83        matches!(self, Self::Hyperinflationary)
84    }
85}
86
87/// Time series of general-price-index (CPI) observations for a
88/// hyperinflationary economy.  The index is monotonically
89/// non-decreasing in normal use; the lookup helpers tolerate
90/// out-of-order dates by sorting on access.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92pub struct GeneralPriceIndex {
93    /// ISO 4217 currency code the index applies to (e.g. "ARS",
94    /// "TRY").  Joins to entity functional currency for lookup.
95    pub currency: String,
96
97    /// Source / methodology label (e.g. "INDEC IPC General",
98    /// "TÜİK CPI").  Carried for audit-trail purposes.
99    pub source: String,
100
101    /// Observed (date, index level) pairs.  Index level convention:
102    /// any positive [`Decimal`] — the helpers compute relative
103    /// indexation factors (`new / old`), so absolute scale is free.
104    pub observations: Vec<(NaiveDate, Decimal)>,
105}
106
107impl GeneralPriceIndex {
108    /// Construct an empty index for `currency` from a labelled
109    /// source.
110    pub fn new(currency: impl Into<String>, source: impl Into<String>) -> Self {
111        Self {
112            currency: currency.into(),
113            source: source.into(),
114            observations: Vec::new(),
115        }
116    }
117
118    /// Append an observation.  Caller is responsible for ordering;
119    /// [`Self::lookup`] sorts on access.
120    pub fn observe(&mut self, date: NaiveDate, level: Decimal) -> &mut Self {
121        self.observations.push((date, level));
122        self
123    }
124
125    /// Look up the index level for a date.  Returns the level on the
126    /// **most recent observation at or before** `date`, mirroring
127    /// the conservative IAS 29 convention of using the latest
128    /// available CPI for each measurement date.  Returns `None` when
129    /// no observation exists at or before the date.
130    pub fn lookup(&self, date: NaiveDate) -> Option<Decimal> {
131        let mut sorted: Vec<&(NaiveDate, Decimal)> = self.observations.iter().collect();
132        sorted.sort_by_key(|(d, _)| *d);
133        sorted
134            .iter()
135            .rev()
136            .find(|(d, _)| *d <= date)
137            .map(|(_, level)| *level)
138    }
139
140    /// Compute the IAS 29 § 12 indexation factor for restating a
141    /// historical-cost amount from `from_date` to `to_date`:
142    ///
143    /// `factor = index(to_date) / index(from_date)`
144    ///
145    /// Returns `None` when either lookup misses, or when the
146    /// `from_date` index is zero (would otherwise divide-by-zero).
147    pub fn indexation_factor(&self, from_date: NaiveDate, to_date: NaiveDate) -> Option<Decimal> {
148        let from = self.lookup(from_date)?;
149        let to = self.lookup(to_date)?;
150        if from.is_zero() {
151            None
152        } else {
153            Some(to / from)
154        }
155    }
156}
157
158/// One line-item restatement under IAS 29 § 12.
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160pub struct IndexedRestatement {
161    /// Account code being restated.
162    pub account_code: String,
163
164    /// The historical date on which the underlying item was
165    /// recognised (acquisition date for non-monetary items).
166    pub historical_date: NaiveDate,
167
168    /// Reporting period end the restatement is being made for.
169    pub reporting_date: NaiveDate,
170
171    /// Pre-restatement (historical-cost) carrying amount, in the
172    /// functional currency.
173    #[serde(with = "crate::serde_decimal")]
174    pub historical_amount: Decimal,
175
176    /// Indexation factor applied =
177    /// `index(reporting_date) / index(historical_date)`.
178    #[serde(with = "crate::serde_decimal")]
179    pub indexation_factor: Decimal,
180
181    /// Post-restatement amount =
182    /// `historical_amount * indexation_factor`.
183    #[serde(with = "crate::serde_decimal")]
184    pub restated_amount: Decimal,
185
186    /// Functional currency code.
187    pub currency: String,
188}
189
190impl IndexedRestatement {
191    /// Restate `historical_amount` from `historical_date` to
192    /// `reporting_date` using the supplied [`GeneralPriceIndex`].
193    /// Returns `None` when the index can't yield a factor for either
194    /// date.  Pure projection — no I/O.
195    pub fn restate(
196        account_code: impl Into<String>,
197        historical_date: NaiveDate,
198        reporting_date: NaiveDate,
199        historical_amount: Decimal,
200        index: &GeneralPriceIndex,
201    ) -> Option<Self> {
202        let factor = index.indexation_factor(historical_date, reporting_date)?;
203        Some(Self {
204            account_code: account_code.into(),
205            historical_date,
206            reporting_date,
207            historical_amount,
208            indexation_factor: factor,
209            restated_amount: (historical_amount * factor).round_dp(2),
210            currency: index.currency.clone(),
211        })
212    }
213
214    /// Restatement adjustment amount =
215    /// `restated_amount − historical_amount`.  Positive when the
216    /// asset's measured value increased (general inflation outpaces
217    /// historical book value); negative when the index has fallen
218    /// (rare — typically only happens with a base-period reset).
219    pub fn adjustment(&self) -> Decimal {
220        self.restated_amount - self.historical_amount
221    }
222}
223
224/// IAS 29 § 27 net-monetary-position gain or loss for a period.
225///
226/// Monetary items (cash, receivables, payables) lose purchasing
227/// power as the general price level rises.  The gain or loss is
228/// recognised in P&L for the period.
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230pub struct NetMonetaryPositionGainLoss {
231    /// Reporting period end.
232    pub reporting_date: NaiveDate,
233
234    /// Opening net monetary position (monetary assets less
235    /// monetary liabilities) at the start of the period, in the
236    /// functional currency.
237    #[serde(with = "crate::serde_decimal")]
238    pub opening_net_monetary_position: Decimal,
239
240    /// Closing net monetary position at `reporting_date`.
241    #[serde(with = "crate::serde_decimal")]
242    pub closing_net_monetary_position: Decimal,
243
244    /// Indexation factor for the period =
245    /// `index(reporting_date) / index(opening_date)`.
246    #[serde(with = "crate::serde_decimal")]
247    pub period_indexation_factor: Decimal,
248
249    /// Computed gain or loss on the net monetary position.  Sign
250    /// convention: a **loss** (negative number) when the entity is a
251    /// net holder of monetary assets in a rising-price environment
252    /// (the typical case in hyperinflation).  A **gain** (positive)
253    /// arises when the entity is a net debtor — its monetary
254    /// liabilities lose purchasing power.
255    #[serde(with = "crate::serde_decimal")]
256    pub gain_or_loss: Decimal,
257
258    /// Functional currency code.
259    pub currency: String,
260}
261
262impl NetMonetaryPositionGainLoss {
263    /// Compute the IAS 29 § 27 gain/loss using the simplified
264    /// **opening-balance restatement** approach:
265    ///
266    /// `gain_or_loss = closing_net_monetary − (opening_net_monetary × factor)`
267    ///
268    /// A more rigorous calculation would index every monetary
269    /// transaction during the period — that's out of scope for v5.2's
270    /// model layer; the wiring layer can refine the input
271    /// aggregation later.
272    pub fn compute(
273        reporting_date: NaiveDate,
274        opening_net_monetary_position: Decimal,
275        closing_net_monetary_position: Decimal,
276        period_indexation_factor: Decimal,
277        currency: impl Into<String>,
278    ) -> Self {
279        let restated_opening = opening_net_monetary_position * period_indexation_factor;
280        let gain_or_loss = (closing_net_monetary_position - restated_opening).round_dp(2);
281        Self {
282            reporting_date,
283            opening_net_monetary_position: opening_net_monetary_position.round_dp(2),
284            closing_net_monetary_position: closing_net_monetary_position.round_dp(2),
285            period_indexation_factor,
286            gain_or_loss,
287            currency: currency.into(),
288        }
289    }
290}
291
292#[cfg(test)]
293#[allow(clippy::unwrap_used)]
294mod tests {
295    use super::*;
296    use rust_decimal_macros::dec;
297
298    fn open_date() -> NaiveDate {
299        NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
300    }
301    fn mid_date() -> NaiveDate {
302        NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()
303    }
304    fn close_date() -> NaiveDate {
305        NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
306    }
307
308    fn ars_index() -> GeneralPriceIndex {
309        let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General");
310        idx.observe(open_date(), dec!(100));
311        idx.observe(mid_date(), dec!(160));
312        idx.observe(close_date(), dec!(220));
313        idx
314    }
315
316    #[test]
317    fn status_requires_restatement_only_when_hyperinflationary() {
318        assert!(!HyperinflationStatus::NotHyperinflationary.requires_restatement());
319        assert!(HyperinflationStatus::Hyperinflationary.requires_restatement());
320    }
321
322    #[test]
323    fn index_lookup_returns_most_recent_at_or_before_date() {
324        let idx = ars_index();
325        // Exact match.
326        assert_eq!(idx.lookup(open_date()), Some(dec!(100)));
327        assert_eq!(idx.lookup(close_date()), Some(dec!(220)));
328        // Between observations: returns the prior observation.
329        let between = NaiveDate::from_ymd_opt(2024, 9, 15).unwrap();
330        assert_eq!(idx.lookup(between), Some(dec!(160)));
331        // Before the first observation: None.
332        let earlier = NaiveDate::from_ymd_opt(2023, 12, 31).unwrap();
333        assert_eq!(idx.lookup(earlier), None);
334    }
335
336    #[test]
337    fn index_lookup_handles_unsorted_observations() {
338        // Insert in reverse order; lookup must still pick the
339        // chronologically most recent ≤ target.
340        let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General");
341        idx.observe(close_date(), dec!(220));
342        idx.observe(open_date(), dec!(100));
343        idx.observe(mid_date(), dec!(160));
344        assert_eq!(idx.lookup(mid_date()), Some(dec!(160)));
345    }
346
347    #[test]
348    fn indexation_factor_is_ratio_of_indices() {
349        let idx = ars_index();
350        // open → close: 220 / 100 = 2.2.
351        let factor = idx.indexation_factor(open_date(), close_date()).unwrap();
352        assert_eq!(factor, dec!(2.2));
353        // mid → close: 220 / 160 = 1.375.
354        let factor = idx.indexation_factor(mid_date(), close_date()).unwrap();
355        assert_eq!(factor, dec!(1.375));
356    }
357
358    #[test]
359    fn indexation_factor_returns_none_on_missing_data() {
360        let idx = ars_index();
361        let pre_index = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
362        // Pre-index date returns None for the from-side.
363        assert_eq!(idx.indexation_factor(pre_index, close_date()), None);
364    }
365
366    #[test]
367    fn restate_applies_factor_to_historical_amount() {
368        let idx = ars_index();
369        let r = IndexedRestatement::restate(
370            "1500", // PP&E
371            open_date(),
372            close_date(),
373            dec!(1_000_000),
374            &idx,
375        )
376        .unwrap();
377        assert_eq!(r.indexation_factor, dec!(2.2));
378        assert_eq!(r.restated_amount, dec!(2_200_000.00));
379        assert_eq!(r.adjustment(), dec!(1_200_000.00));
380        assert_eq!(r.currency, "ARS");
381    }
382
383    #[test]
384    fn restate_returns_none_when_factor_unavailable() {
385        let idx = ars_index();
386        let pre_index = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
387        assert!(IndexedRestatement::restate(
388            "1500",
389            pre_index,
390            close_date(),
391            dec!(1_000_000),
392            &idx
393        )
394        .is_none());
395    }
396
397    #[test]
398    fn net_monetary_loss_for_a_net_holder_of_cash() {
399        // Entity is a net holder of monetary assets.  Opening net =
400        // 100k, closing net = 180k after a year of 120% inflation
401        // (factor 2.2).  Restated opening = 100k × 2.2 = 220k.
402        // Loss = closing 180k − restated 220k = −40k (loss in P&L).
403        let result = NetMonetaryPositionGainLoss::compute(
404            close_date(),
405            dec!(100_000),
406            dec!(180_000),
407            dec!(2.2),
408            "ARS",
409        );
410        assert_eq!(result.gain_or_loss, dec!(-40_000.00));
411    }
412
413    #[test]
414    fn net_monetary_gain_for_a_net_debtor() {
415        // Entity is a net debtor (negative net monetary position).
416        // Opening = −500k, closing = −540k, factor 2.2.  Restated
417        // opening = −500k × 2.2 = −1.1M.  Gain = −540k − (−1.1M) =
418        // +560k (purchasing power gain on debt).
419        let result = NetMonetaryPositionGainLoss::compute(
420            close_date(),
421            dec!(-500_000),
422            dec!(-540_000),
423            dec!(2.2),
424            "ARS",
425        );
426        assert_eq!(result.gain_or_loss, dec!(560_000.00));
427    }
428
429    #[test]
430    fn round_trips_serialise_for_audit_evidence() {
431        let idx = ars_index();
432        let json = serde_json::to_string(&idx).unwrap();
433        let back: GeneralPriceIndex = serde_json::from_str(&json).unwrap();
434        assert_eq!(back, idx);
435
436        let r =
437            IndexedRestatement::restate("1500", open_date(), close_date(), dec!(1_000_000), &idx)
438                .unwrap();
439        let json = serde_json::to_string(&r).unwrap();
440        let back: IndexedRestatement = serde_json::from_str(&json).unwrap();
441        assert_eq!(back, r);
442
443        let gl = NetMonetaryPositionGainLoss::compute(
444            close_date(),
445            dec!(100_000),
446            dec!(180_000),
447            dec!(2.2),
448            "ARS",
449        );
450        let json = serde_json::to_string(&gl).unwrap();
451        let back: NetMonetaryPositionGainLoss = serde_json::from_str(&json).unwrap();
452        assert_eq!(back, gl);
453    }
454}