iati_transform/
lib.rs

1
2use chrono::Datelike;
3use iati_types::{money::CurrencyCode, tx::TxType, Activity};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum TransformError {
11    #[error("missing currency (no value currency and no activity default)")]
12    MissingCurrency,
13}
14
15#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum FxCurrency {
18    /// Keep each transaction's native currency (value.currency or activity.default_currency).
19    Native,
20    /// Convert everything to target currency with a placeholder 1:1 rate for now.
21    /// (Real FX will be provided by a future iati-fx crate.)
22    Fixed { target: CurrencyCode },
23}
24
25#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct ByTypeAndCurrency {
28    // TxType -> Currency -> Decimal
29    pub sums: BTreeMap<TxType, BTreeMap<CurrencyCode, Decimal>>,
30}
31
32impl ByTypeAndCurrency {
33    pub fn add(&mut self, tx_type: TxType, currency: CurrencyCode, amount: Decimal) {
34        self.sums
35            .entry(tx_type)
36            .or_default()
37            .entry(currency)
38            .and_modify(|x| *x += amount)
39            .or_insert(amount);
40    }
41
42    pub fn total_for(&self, tx_type: TxType, currency: &CurrencyCode) -> Option<Decimal> {
43        self.sums.get(&tx_type)?.get(currency).cloned()
44    }
45}
46
47/// Resolve the currency for a transaction (value.currency or activity.default_currency).
48fn resolve_currency(act: &Activity, currency: Option<CurrencyCode>) -> Result<CurrencyCode, TransformError> {
49    match currency.or_else(|| act.default_currency.clone()) {
50        Some(c) => Ok(c),
51        None => Err(TransformError::MissingCurrency),
52    }
53}
54
55/// Apply FX strategy. For now only Native or Fixed{target} with a 1:1 rate.
56/// (A future iati-fx crate will supply actual FX conversions.)
57fn apply_fx(_value_date: Option<chrono::NaiveDate>, amount: Decimal, from: &CurrencyCode, fx: &FxCurrency)
58    -> (Decimal, CurrencyCode)
59{
60    match fx {
61        FxCurrency::Native => (amount, from.clone()),
62        FxCurrency::Fixed { target } => {
63            // placeholder: 1:1 rate; swap to target currency
64            (amount, target.clone())
65        }
66    }
67}
68
69/// Aggregate sums by TxType and Currency across many activities.
70/// - Currency resolution: value.currency -> act.default_currency -> error.
71/// - FX: Native (no conversion) or Fixed{target} (placeholder 1:1).
72pub fn aggregate_by_type(activities: &[Activity], fx: FxCurrency) -> ByTypeAndCurrency {
73    let mut out = ByTypeAndCurrency::default();
74
75    for act in activities {
76        for tx in &act.transactions {
77            // resolve currency
78            let src_cur = match resolve_currency(act, tx.value.currency.clone()) {
79                Ok(c) => c,
80                Err(_) => continue, // skip transactions without any currency info
81            };
82
83            let (amt, cur) = apply_fx(tx.value.value_date, tx.value.amount, &src_cur, &fx);
84            out.add(tx.tx_type, cur, amt);
85        }
86    }
87
88    out
89}
90
91#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
92#[derive(Debug, Clone, PartialEq, Eq, Default)]
93pub struct ByYearTypeAndCurrency {
94    /// year -> TxType -> Currency -> Decimal
95    pub sums: BTreeMap<i32, BTreeMap<TxType, BTreeMap<CurrencyCode, Decimal>>>,
96}
97
98impl ByYearTypeAndCurrency {
99    pub fn add(&mut self, year: i32, tx_type: TxType, currency: CurrencyCode, amount: Decimal) {
100        self.sums
101            .entry(year)
102            .or_default()
103            .entry(tx_type)
104            .or_default()
105            .entry(currency)
106            .and_modify(|x| *x += amount)
107            .or_insert(amount);
108    }
109}
110
111/// Aggregate by (year, type, currency). Uses `transaction.date.year()`.
112pub fn aggregate_by_year_and_type(activities: &[Activity], fx: FxCurrency) -> ByYearTypeAndCurrency {
113    let mut out = ByYearTypeAndCurrency::default();
114
115    for act in activities {
116        for tx in &act.transactions {
117            let year = tx.date.year();
118            let src_cur = match resolve_currency(act, tx.value.currency.clone()) {
119                Ok(c) => c,
120                Err(_) => continue,
121            };
122            let (amt, cur) = apply_fx(tx.value.value_date, tx.value.amount, &src_cur, &fx);
123            out.add(year, tx.tx_type, cur, amt);
124        }
125    }
126
127    out
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use iati_types::{money::Money, tx::Transaction, Activity, TxType};
134    use chrono::NaiveDate;
135    use rust_decimal::Decimal;
136
137    fn mk_money(amount_cents: i64, currency: Option<&str>) -> Money {
138        Money {
139            amount: Decimal::new(amount_cents, 2),
140            currency: currency.map(|c| CurrencyCode::from(c)),
141            value_date: None,
142        }
143    }
144
145    #[test]
146    fn sum_by_type_native_currency() {
147        let mut a = Activity::new("A1");
148        a.default_currency = Some(CurrencyCode::from("USD"));
149
150        a.transactions.push(Transaction::new(
151            TxType::Disbursement,
152            NaiveDate::from_ymd_opt(2023, 1, 10).unwrap(),
153            mk_money(1000, None), // 10.00 USD (falls back to activity default)
154        ));
155        a.transactions.push(Transaction::new(
156            TxType::Disbursement,
157            NaiveDate::from_ymd_opt(2023, 2, 10).unwrap(),
158            mk_money(500, Some("EUR")), // 5.00 EUR
159        ));
160        a.transactions.push(Transaction::new(
161            TxType::OutgoingCommitment,
162            NaiveDate::from_ymd_opt(2023, 2, 10).unwrap(),
163            mk_money(700, Some("USD")), // 7.00 USD
164        ));
165
166        let sums = aggregate_by_type(&[a], FxCurrency::Native);
167        assert_eq!(
168            sums.total_for(TxType::Disbursement, &CurrencyCode::from("USD")).unwrap(),
169            Decimal::new(1000, 2)
170        );
171        assert_eq!(
172            sums.total_for(TxType::Disbursement, &CurrencyCode::from("EUR")).unwrap(),
173            Decimal::new(500, 2)
174        );
175        assert_eq!(
176            sums.total_for(TxType::OutgoingCommitment, &CurrencyCode::from("USD")).unwrap(),
177            Decimal::new(700, 2)
178        );
179    }
180
181    #[test]
182    fn sum_by_year_and_type_fixed_target() {
183        let mut a = Activity::new("A1");
184        a.default_currency = Some(CurrencyCode::from("USD"));
185
186        a.transactions.push(Transaction::new(
187            TxType::Disbursement,
188            NaiveDate::from_ymd_opt(2023, 1, 10).unwrap(),
189            mk_money(1000, None), // 10.00 USD
190        ));
191        a.transactions.push(Transaction::new(
192            TxType::Disbursement,
193            NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
194            mk_money(500, Some("EUR")), // 5.00 EUR -> Fixed target GBP (1:1)
195        ));
196
197        let sums = aggregate_by_year_and_type(&[a], FxCurrency::Fixed { target: CurrencyCode::from("GBP") });
198        use rust_decimal::prelude::ToPrimitive;
199        // 2023: 10.00 -> GBP
200        assert_eq!(
201            sums.sums.get(&2023).unwrap()
202                .get(&TxType::Disbursement).unwrap()
203                .get(&CurrencyCode::from("GBP")).unwrap().to_f64().unwrap(),
204            10.00_f64
205        );
206        // 2024: 5.00 -> GBP
207        assert_eq!(
208            sums.sums.get(&2024).unwrap()
209                .get(&TxType::Disbursement).unwrap()
210                .get(&CurrencyCode::from("GBP")).unwrap().to_f64().unwrap(),
211            5.00_f64
212        );
213    }
214}