use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum HyperinflationStatus {
#[default]
NotHyperinflationary,
Hyperinflationary,
}
impl HyperinflationStatus {
pub fn requires_restatement(&self) -> bool {
matches!(self, Self::Hyperinflationary)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GeneralPriceIndex {
pub currency: String,
pub source: String,
pub observations: Vec<(NaiveDate, Decimal)>,
}
impl GeneralPriceIndex {
pub fn new(currency: impl Into<String>, source: impl Into<String>) -> Self {
Self {
currency: currency.into(),
source: source.into(),
observations: Vec::new(),
}
}
pub fn observe(&mut self, date: NaiveDate, level: Decimal) -> &mut Self {
self.observations.push((date, level));
self
}
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)
}
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)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IndexedRestatement {
pub account_code: String,
pub historical_date: NaiveDate,
pub reporting_date: NaiveDate,
#[serde(with = "crate::serde_decimal")]
pub historical_amount: Decimal,
#[serde(with = "crate::serde_decimal")]
pub indexation_factor: Decimal,
#[serde(with = "crate::serde_decimal")]
pub restated_amount: Decimal,
pub currency: String,
}
impl IndexedRestatement {
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(),
})
}
pub fn adjustment(&self) -> Decimal {
self.restated_amount - self.historical_amount
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetMonetaryPositionGainLoss {
pub reporting_date: NaiveDate,
#[serde(with = "crate::serde_decimal")]
pub opening_net_monetary_position: Decimal,
#[serde(with = "crate::serde_decimal")]
pub closing_net_monetary_position: Decimal,
#[serde(with = "crate::serde_decimal")]
pub period_indexation_factor: Decimal,
#[serde(with = "crate::serde_decimal")]
pub gain_or_loss: Decimal,
pub currency: String,
}
impl NetMonetaryPositionGainLoss {
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();
assert_eq!(idx.lookup(open_date()), Some(dec!(100)));
assert_eq!(idx.lookup(close_date()), Some(dec!(220)));
let between = NaiveDate::from_ymd_opt(2024, 9, 15).unwrap();
assert_eq!(idx.lookup(between), Some(dec!(160)));
let earlier = NaiveDate::from_ymd_opt(2023, 12, 31).unwrap();
assert_eq!(idx.lookup(earlier), None);
}
#[test]
fn index_lookup_handles_unsorted_observations() {
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();
let factor = idx.indexation_factor(open_date(), close_date()).unwrap();
assert_eq!(factor, dec!(2.2));
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();
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", 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() {
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() {
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);
}
}