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 Native,
20 Fixed { target: CurrencyCode },
23}
24
25#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct ByTypeAndCurrency {
28 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
47fn 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
55fn 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 (amount, target.clone())
65 }
66 }
67}
68
69pub 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 let src_cur = match resolve_currency(act, tx.value.currency.clone()) {
79 Ok(c) => c,
80 Err(_) => continue, };
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 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
111pub 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), ));
155 a.transactions.push(Transaction::new(
156 TxType::Disbursement,
157 NaiveDate::from_ymd_opt(2023, 2, 10).unwrap(),
158 mk_money(500, Some("EUR")), ));
160 a.transactions.push(Transaction::new(
161 TxType::OutgoingCommitment,
162 NaiveDate::from_ymd_opt(2023, 2, 10).unwrap(),
163 mk_money(700, Some("USD")), ));
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), ));
191 a.transactions.push(Transaction::new(
192 TxType::Disbursement,
193 NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
194 mk_money(500, Some("EUR")), ));
196
197 let sums = aggregate_by_year_and_type(&[a], FxCurrency::Fixed { target: CurrencyCode::from("GBP") });
198 use rust_decimal::prelude::ToPrimitive;
199 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 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}