datasynth-core 5.8.0

Core domain models, traits, and distributions for synthetic enterprise data generation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! Hyperinflationary-economy accounting under IAS 29 / ASC 830.
//!
//! When an entity's functional currency is the currency of a
//! hyperinflationary economy, IAS 29 requires the entity to restate
//! its non-monetary items using a general price index so the
//! financial statements are stated in terms of the **measuring unit
//! current at the end of the reporting period**.  v5.0 / v5.1 did not
//! handle this — every entity went through the standard IAS 21
//! translation path which produces nonsense results once cumulative
//! inflation crosses ~100 %.
//!
//! v5.2 ships the typed model + arithmetic helpers; the integration
//! with the IAS 21 translation pipeline (`translate_entity_tb` /
//! `cta_rollforward`) is a follow-up.
//!
//! # Standards reference
//!
//! - **IAS 29 § 3** — characteristics of a hyperinflationary economy
//!   (cumulative 3-year inflation approaching or exceeding 100 %; the
//!   general population prefers a stable foreign currency to keep its
//!   wealth; etc.).  IAS 29 does not establish an absolute rate at
//!   which hyperinflation is deemed to arise — it's a matter of
//!   judgement.
//! - **IAS 29 § 8** — the financial statements **shall be stated in
//!   terms of the measuring unit current at the end of the reporting
//!   period**.  Comparative figures are also restated.
//! - **IAS 29 § 12** — non-monetary items carried at historical cost
//!   are restated by applying the change in the general price index
//!   between the date of acquisition (or revaluation) and the
//!   reporting date.
//! - **IAS 29 § 13** — non-monetary items at current value (e.g.
//!   inventories at NRV) are NOT restated (already at current
//!   measuring unit).
//! - **IAS 29 § 27** — the **net gain or loss on the net monetary
//!   position** is included in profit or loss for the period.  It
//!   represents the loss of purchasing power on monetary items
//!   (cash, receivables, payables).
//! - **IAS 21 § 39 / IAS 29 § 33** — when restated financial
//!   statements of a hyperinflationary subsidiary are translated
//!   into the group's (non-hyperinflationary) presentation
//!   currency, the **closing rate** is used for ALL items (not the
//!   spot/average split that IAS 21 normally prescribes).
//!
//! # Scope
//!
//! v5.2 ships:
//!
//! - [`HyperinflationStatus`] entity-level flag
//! - [`GeneralPriceIndex`] CPI series + index lookup
//! - [`IndexedRestatement`] line-item restatement record
//! - [`NetMonetaryPositionGainLoss`] helper for the IAS 29 § 27
//!   purchasing-power gain/loss calc
//!
//! The wiring to actually drive these through `translate_entity_tb`
//! is a follow-up tracked in the README.

use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};

/// Hyperinflation status of an entity's functional currency.
/// Captured per-entity per-period because a country can transition
/// in or out of hyperinflation across reporting cycles (e.g.
/// Argentina entered hyperinflationary status in 2018 per IAS 29
/// criteria; Türkiye did so in 2022).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum HyperinflationStatus {
    /// The functional currency is **not** hyperinflationary.
    /// Standard IAS 21 translation applies.
    #[default]
    NotHyperinflationary,
    /// The functional currency **is** hyperinflationary; IAS 29
    /// restatement applies before IAS 21 translation per IAS 21 § 43.
    /// The closing rate is used for all items per IAS 21 § 42(b).
    Hyperinflationary,
}

impl HyperinflationStatus {
    /// Returns `true` when this status requires IAS 29 restatement
    /// before IAS 21 translation.
    pub fn requires_restatement(&self) -> bool {
        matches!(self, Self::Hyperinflationary)
    }
}

/// Time series of general-price-index (CPI) observations for a
/// hyperinflationary economy.  The index is monotonically
/// non-decreasing in normal use; the lookup helpers tolerate
/// out-of-order dates by sorting on access.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GeneralPriceIndex {
    /// ISO 4217 currency code the index applies to (e.g. "ARS",
    /// "TRY").  Joins to entity functional currency for lookup.
    pub currency: String,

    /// Source / methodology label (e.g. "INDEC IPC General",
    /// "TÜİK CPI").  Carried for audit-trail purposes.
    pub source: String,

    /// Observed (date, index level) pairs.  Index level convention:
    /// any positive [`Decimal`] — the helpers compute relative
    /// indexation factors (`new / old`), so absolute scale is free.
    pub observations: Vec<(NaiveDate, Decimal)>,
}

impl GeneralPriceIndex {
    /// Construct an empty index for `currency` from a labelled
    /// source.
    pub fn new(currency: impl Into<String>, source: impl Into<String>) -> Self {
        Self {
            currency: currency.into(),
            source: source.into(),
            observations: Vec::new(),
        }
    }

    /// Append an observation.  Caller is responsible for ordering;
    /// [`Self::lookup`] sorts on access.
    pub fn observe(&mut self, date: NaiveDate, level: Decimal) -> &mut Self {
        self.observations.push((date, level));
        self
    }

    /// Look up the index level for a date.  Returns the level on the
    /// **most recent observation at or before** `date`, mirroring
    /// the conservative IAS 29 convention of using the latest
    /// available CPI for each measurement date.  Returns `None` when
    /// no observation exists at or before the date.
    pub fn lookup(&self, date: NaiveDate) -> Option<Decimal> {
        let mut sorted: Vec<&(NaiveDate, Decimal)> = self.observations.iter().collect();
        sorted.sort_by_key(|(d, _)| *d);
        sorted
            .iter()
            .rev()
            .find(|(d, _)| *d <= date)
            .map(|(_, level)| *level)
    }

    /// Compute the IAS 29 § 12 indexation factor for restating a
    /// historical-cost amount from `from_date` to `to_date`:
    ///
    /// `factor = index(to_date) / index(from_date)`
    ///
    /// Returns `None` when either lookup misses, or when the
    /// `from_date` index is zero (would otherwise divide-by-zero).
    pub fn indexation_factor(&self, from_date: NaiveDate, to_date: NaiveDate) -> Option<Decimal> {
        let from = self.lookup(from_date)?;
        let to = self.lookup(to_date)?;
        if from.is_zero() {
            None
        } else {
            Some(to / from)
        }
    }
}

/// One line-item restatement under IAS 29 § 12.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IndexedRestatement {
    /// Account code being restated.
    pub account_code: String,

    /// The historical date on which the underlying item was
    /// recognised (acquisition date for non-monetary items).
    pub historical_date: NaiveDate,

    /// Reporting period end the restatement is being made for.
    pub reporting_date: NaiveDate,

    /// Pre-restatement (historical-cost) carrying amount, in the
    /// functional currency.
    #[serde(with = "crate::serde_decimal")]
    pub historical_amount: Decimal,

    /// Indexation factor applied =
    /// `index(reporting_date) / index(historical_date)`.
    #[serde(with = "crate::serde_decimal")]
    pub indexation_factor: Decimal,

    /// Post-restatement amount =
    /// `historical_amount * indexation_factor`.
    #[serde(with = "crate::serde_decimal")]
    pub restated_amount: Decimal,

    /// Functional currency code.
    pub currency: String,
}

impl IndexedRestatement {
    /// Restate `historical_amount` from `historical_date` to
    /// `reporting_date` using the supplied [`GeneralPriceIndex`].
    /// Returns `None` when the index can't yield a factor for either
    /// date.  Pure projection — no I/O.
    pub fn restate(
        account_code: impl Into<String>,
        historical_date: NaiveDate,
        reporting_date: NaiveDate,
        historical_amount: Decimal,
        index: &GeneralPriceIndex,
    ) -> Option<Self> {
        let factor = index.indexation_factor(historical_date, reporting_date)?;
        Some(Self {
            account_code: account_code.into(),
            historical_date,
            reporting_date,
            historical_amount,
            indexation_factor: factor,
            restated_amount: (historical_amount * factor).round_dp(2),
            currency: index.currency.clone(),
        })
    }

    /// Restatement adjustment amount =
    /// `restated_amount − historical_amount`.  Positive when the
    /// asset's measured value increased (general inflation outpaces
    /// historical book value); negative when the index has fallen
    /// (rare — typically only happens with a base-period reset).
    pub fn adjustment(&self) -> Decimal {
        self.restated_amount - self.historical_amount
    }
}

/// IAS 29 § 27 net-monetary-position gain or loss for a period.
///
/// Monetary items (cash, receivables, payables) lose purchasing
/// power as the general price level rises.  The gain or loss is
/// recognised in P&L for the period.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetMonetaryPositionGainLoss {
    /// Reporting period end.
    pub reporting_date: NaiveDate,

    /// Opening net monetary position (monetary assets less
    /// monetary liabilities) at the start of the period, in the
    /// functional currency.
    #[serde(with = "crate::serde_decimal")]
    pub opening_net_monetary_position: Decimal,

    /// Closing net monetary position at `reporting_date`.
    #[serde(with = "crate::serde_decimal")]
    pub closing_net_monetary_position: Decimal,

    /// Indexation factor for the period =
    /// `index(reporting_date) / index(opening_date)`.
    #[serde(with = "crate::serde_decimal")]
    pub period_indexation_factor: Decimal,

    /// Computed gain or loss on the net monetary position.  Sign
    /// convention: a **loss** (negative number) when the entity is a
    /// net holder of monetary assets in a rising-price environment
    /// (the typical case in hyperinflation).  A **gain** (positive)
    /// arises when the entity is a net debtor — its monetary
    /// liabilities lose purchasing power.
    #[serde(with = "crate::serde_decimal")]
    pub gain_or_loss: Decimal,

    /// Functional currency code.
    pub currency: String,
}

impl NetMonetaryPositionGainLoss {
    /// Compute the IAS 29 § 27 gain/loss using the simplified
    /// **opening-balance restatement** approach:
    ///
    /// `gain_or_loss = closing_net_monetary − (opening_net_monetary × factor)`
    ///
    /// A more rigorous calculation would index every monetary
    /// transaction during the period — that's out of scope for v5.2's
    /// model layer; the wiring layer can refine the input
    /// aggregation later.
    pub fn compute(
        reporting_date: NaiveDate,
        opening_net_monetary_position: Decimal,
        closing_net_monetary_position: Decimal,
        period_indexation_factor: Decimal,
        currency: impl Into<String>,
    ) -> Self {
        let restated_opening = opening_net_monetary_position * period_indexation_factor;
        let gain_or_loss = (closing_net_monetary_position - restated_opening).round_dp(2);
        Self {
            reporting_date,
            opening_net_monetary_position: opening_net_monetary_position.round_dp(2),
            closing_net_monetary_position: closing_net_monetary_position.round_dp(2),
            period_indexation_factor,
            gain_or_loss,
            currency: currency.into(),
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    fn open_date() -> NaiveDate {
        NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
    }
    fn mid_date() -> NaiveDate {
        NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()
    }
    fn close_date() -> NaiveDate {
        NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
    }

    fn ars_index() -> GeneralPriceIndex {
        let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General");
        idx.observe(open_date(), dec!(100));
        idx.observe(mid_date(), dec!(160));
        idx.observe(close_date(), dec!(220));
        idx
    }

    #[test]
    fn status_requires_restatement_only_when_hyperinflationary() {
        assert!(!HyperinflationStatus::NotHyperinflationary.requires_restatement());
        assert!(HyperinflationStatus::Hyperinflationary.requires_restatement());
    }

    #[test]
    fn index_lookup_returns_most_recent_at_or_before_date() {
        let idx = ars_index();
        // Exact match.
        assert_eq!(idx.lookup(open_date()), Some(dec!(100)));
        assert_eq!(idx.lookup(close_date()), Some(dec!(220)));
        // Between observations: returns the prior observation.
        let between = NaiveDate::from_ymd_opt(2024, 9, 15).unwrap();
        assert_eq!(idx.lookup(between), Some(dec!(160)));
        // Before the first observation: None.
        let earlier = NaiveDate::from_ymd_opt(2023, 12, 31).unwrap();
        assert_eq!(idx.lookup(earlier), None);
    }

    #[test]
    fn index_lookup_handles_unsorted_observations() {
        // Insert in reverse order; lookup must still pick the
        // chronologically most recent ≤ target.
        let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General");
        idx.observe(close_date(), dec!(220));
        idx.observe(open_date(), dec!(100));
        idx.observe(mid_date(), dec!(160));
        assert_eq!(idx.lookup(mid_date()), Some(dec!(160)));
    }

    #[test]
    fn indexation_factor_is_ratio_of_indices() {
        let idx = ars_index();
        // open → close: 220 / 100 = 2.2.
        let factor = idx.indexation_factor(open_date(), close_date()).unwrap();
        assert_eq!(factor, dec!(2.2));
        // mid → close: 220 / 160 = 1.375.
        let factor = idx.indexation_factor(mid_date(), close_date()).unwrap();
        assert_eq!(factor, dec!(1.375));
    }

    #[test]
    fn indexation_factor_returns_none_on_missing_data() {
        let idx = ars_index();
        let pre_index = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
        // Pre-index date returns None for the from-side.
        assert_eq!(idx.indexation_factor(pre_index, close_date()), None);
    }

    #[test]
    fn restate_applies_factor_to_historical_amount() {
        let idx = ars_index();
        let r = IndexedRestatement::restate(
            "1500", // PP&E
            open_date(),
            close_date(),
            dec!(1_000_000),
            &idx,
        )
        .unwrap();
        assert_eq!(r.indexation_factor, dec!(2.2));
        assert_eq!(r.restated_amount, dec!(2_200_000.00));
        assert_eq!(r.adjustment(), dec!(1_200_000.00));
        assert_eq!(r.currency, "ARS");
    }

    #[test]
    fn restate_returns_none_when_factor_unavailable() {
        let idx = ars_index();
        let pre_index = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
        assert!(IndexedRestatement::restate(
            "1500",
            pre_index,
            close_date(),
            dec!(1_000_000),
            &idx
        )
        .is_none());
    }

    #[test]
    fn net_monetary_loss_for_a_net_holder_of_cash() {
        // Entity is a net holder of monetary assets.  Opening net =
        // 100k, closing net = 180k after a year of 120% inflation
        // (factor 2.2).  Restated opening = 100k × 2.2 = 220k.
        // Loss = closing 180k − restated 220k = −40k (loss in P&L).
        let result = NetMonetaryPositionGainLoss::compute(
            close_date(),
            dec!(100_000),
            dec!(180_000),
            dec!(2.2),
            "ARS",
        );
        assert_eq!(result.gain_or_loss, dec!(-40_000.00));
    }

    #[test]
    fn net_monetary_gain_for_a_net_debtor() {
        // Entity is a net debtor (negative net monetary position).
        // Opening = −500k, closing = −540k, factor 2.2.  Restated
        // opening = −500k × 2.2 = −1.1M.  Gain = −540k − (−1.1M) =
        // +560k (purchasing power gain on debt).
        let result = NetMonetaryPositionGainLoss::compute(
            close_date(),
            dec!(-500_000),
            dec!(-540_000),
            dec!(2.2),
            "ARS",
        );
        assert_eq!(result.gain_or_loss, dec!(560_000.00));
    }

    #[test]
    fn round_trips_serialise_for_audit_evidence() {
        let idx = ars_index();
        let json = serde_json::to_string(&idx).unwrap();
        let back: GeneralPriceIndex = serde_json::from_str(&json).unwrap();
        assert_eq!(back, idx);

        let r =
            IndexedRestatement::restate("1500", open_date(), close_date(), dec!(1_000_000), &idx)
                .unwrap();
        let json = serde_json::to_string(&r).unwrap();
        let back: IndexedRestatement = serde_json::from_str(&json).unwrap();
        assert_eq!(back, r);

        let gl = NetMonetaryPositionGainLoss::compute(
            close_date(),
            dec!(100_000),
            dec!(180_000),
            dec!(2.2),
            "ARS",
        );
        let json = serde_json::to_string(&gl).unwrap();
        let back: NetMonetaryPositionGainLoss = serde_json::from_str(&json).unwrap();
        assert_eq!(back, gl);
    }
}