1use chrono::NaiveDate;
18use rust_decimal::Decimal;
19use rust_decimal_macros::dec;
20
21use datasynth_core::models::currency_translation_result::{
22 CurrencyTranslationResult, Ias21TranslationMethod, TranslatedLineItem, TranslationRateType,
23};
24use datasynth_core::models::{FxRateTable, RateType};
25
26static SYNTHETIC_ACCOUNTS: &[(&str, &str, bool, bool, f64)] = &[
36 ("1000", "Asset", true, false, 0.20), ("1100", "Asset", true, false, 0.30), ("2000", "Liability", true, false, -0.25), ("2100", "Liability", true, false, -0.10), ("1500", "Asset", false, false, 0.15), ("1600", "Asset", false, false, 0.40), ("3100", "Equity", false, false, -0.50), ("3300", "Equity", false, false, -0.20), ("4000", "Revenue", false, true, 1.00), ("5000", "Expense", false, true, -0.60), ("6000", "Expense", false, true, -0.25), ];
52
53pub struct FunctionalCurrencyTranslator;
55
56impl FunctionalCurrencyTranslator {
57 pub fn translate(
71 entity_code: &str,
72 functional_currency: &str,
73 presentation_currency: &str,
74 period_label: &str,
75 period_end: NaiveDate,
76 revenue_proxy: Decimal,
77 rate_table: &FxRateTable,
78 ) -> CurrencyTranslationResult {
79 if functional_currency.to_uppercase() == presentation_currency.to_uppercase() {
81 return Self::identity_result(
82 entity_code,
83 functional_currency,
84 presentation_currency,
85 period_label,
86 revenue_proxy,
87 );
88 }
89
90 let closing_rate = rate_table
92 .get_closing_rate(functional_currency, presentation_currency, period_end)
93 .map(|r| r.rate)
94 .unwrap_or(Decimal::ONE);
95
96 let average_rate = rate_table
97 .get_average_rate(functional_currency, presentation_currency, period_end)
98 .map(|r| r.rate)
99 .unwrap_or(closing_rate);
100
101 let historical_rate = rate_table
106 .get_rate(
107 functional_currency,
108 presentation_currency,
109 &RateType::Historical,
110 period_end,
111 )
112 .map(|r| r.rate)
113 .unwrap_or_else(|| {
114 (closing_rate * dec!(0.95)).round_dp(6)
116 });
117
118 let mut translated_items: Vec<TranslatedLineItem> = Vec::new();
119 let mut total_bs_functional = Decimal::ZERO;
120 let mut total_bs_presentation = Decimal::ZERO;
121 let mut total_pnl_functional = Decimal::ZERO;
122 let mut total_pnl_presentation = Decimal::ZERO;
123
124 for &(account, type_label, is_monetary_bs, is_pnl, factor) in SYNTHETIC_ACCOUNTS {
125 let func_amount =
126 (revenue_proxy * Decimal::try_from(factor).unwrap_or(Decimal::ZERO)).round_dp(2);
127
128 let (rate_used, rate_type) = if is_pnl {
129 (average_rate, TranslationRateType::AverageRate)
130 } else if is_monetary_bs {
131 (closing_rate, TranslationRateType::ClosingRate)
132 } else {
133 (historical_rate, TranslationRateType::HistoricalRate)
135 };
136
137 let pres_amount = (func_amount * rate_used).round_dp(2);
138
139 if is_pnl {
140 total_pnl_functional += func_amount;
141 total_pnl_presentation += pres_amount;
142 } else {
143 total_bs_functional += func_amount;
144 total_bs_presentation += pres_amount;
145 }
146
147 translated_items.push(TranslatedLineItem {
148 account: account.to_string(),
149 account_type: type_label.to_string(),
150 functional_amount: func_amount,
151 rate_used,
152 rate_type,
153 presentation_amount: pres_amount,
154 });
155 }
156
157 let all_closing_bs = (total_bs_functional * closing_rate).round_dp(2);
166 let cta_amount = (all_closing_bs - total_bs_presentation).round_dp(2);
167
168 CurrencyTranslationResult {
169 entity_code: entity_code.to_string(),
170 functional_currency: functional_currency.to_uppercase(),
171 presentation_currency: presentation_currency.to_uppercase(),
172 period: period_label.to_string(),
173 translation_method: Ias21TranslationMethod::CurrentRate,
174 translated_items,
175 cta_amount,
176 closing_rate,
177 average_rate,
178 total_balance_sheet_functional: total_bs_functional,
179 total_balance_sheet_presentation: total_bs_presentation,
180 total_pnl_functional,
181 total_pnl_presentation,
182 }
183 }
184
185 fn identity_result(
187 entity_code: &str,
188 functional_currency: &str,
189 presentation_currency: &str,
190 period_label: &str,
191 revenue_proxy: Decimal,
192 ) -> CurrencyTranslationResult {
193 let mut translated_items: Vec<TranslatedLineItem> = Vec::new();
194 let mut total_bs_functional = Decimal::ZERO;
195 let mut total_pnl_functional = Decimal::ZERO;
196
197 for &(account, type_label, _, is_pnl, factor) in SYNTHETIC_ACCOUNTS {
198 let func_amount =
199 (revenue_proxy * Decimal::try_from(factor).unwrap_or(Decimal::ZERO)).round_dp(2);
200
201 if is_pnl {
202 total_pnl_functional += func_amount;
203 } else {
204 total_bs_functional += func_amount;
205 }
206
207 translated_items.push(TranslatedLineItem {
208 account: account.to_string(),
209 account_type: type_label.to_string(),
210 functional_amount: func_amount,
211 rate_used: Decimal::ONE,
212 rate_type: TranslationRateType::NoTranslation,
213 presentation_amount: func_amount,
214 });
215 }
216
217 CurrencyTranslationResult {
218 entity_code: entity_code.to_string(),
219 functional_currency: functional_currency.to_uppercase(),
220 presentation_currency: presentation_currency.to_uppercase(),
221 period: period_label.to_string(),
222 translation_method: Ias21TranslationMethod::CurrentRate,
223 translated_items,
224 cta_amount: Decimal::ZERO,
225 closing_rate: Decimal::ONE,
226 average_rate: Decimal::ONE,
227 total_balance_sheet_functional: total_bs_functional,
228 total_balance_sheet_presentation: total_bs_functional, total_pnl_functional,
230 total_pnl_presentation: total_pnl_functional,
231 }
232 }
233}
234
235#[cfg(test)]
236#[allow(clippy::unwrap_used)]
237mod tests {
238 use super::*;
239 use datasynth_core::models::{FxRate, FxRateTable, RateType};
240 use rust_decimal_macros::dec;
241
242 fn make_rate_table() -> FxRateTable {
243 let mut table = FxRateTable::new("USD");
244 let period_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
245
246 table.add_rate(FxRate::new(
247 "EUR",
248 "USD",
249 RateType::Closing,
250 period_end,
251 dec!(1.12),
252 "TEST",
253 ));
254 table.add_rate(FxRate::new(
255 "EUR",
256 "USD",
257 RateType::Average,
258 period_end,
259 dec!(1.08),
260 "TEST",
261 ));
262 table
263 }
264
265 #[test]
266 fn test_same_currency_no_translation() {
267 let table = make_rate_table();
268 let result = FunctionalCurrencyTranslator::translate(
269 "1000",
270 "USD",
271 "USD",
272 "2024-12",
273 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
274 dec!(1_000_000),
275 &table,
276 );
277
278 assert_eq!(result.cta_amount, Decimal::ZERO);
279 assert_eq!(result.closing_rate, Decimal::ONE);
280 assert!(result
281 .translated_items
282 .iter()
283 .all(|i| i.rate_type == TranslationRateType::NoTranslation));
284 }
285
286 #[test]
287 fn test_different_currency_cta_non_zero() {
288 let table = make_rate_table();
289 let result = FunctionalCurrencyTranslator::translate(
290 "1200",
291 "EUR",
292 "USD",
293 "2024-12",
294 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
295 dec!(1_000_000),
296 &table,
297 );
298
299 assert_ne!(result.cta_amount, Decimal::ZERO);
301 assert_eq!(result.closing_rate, dec!(1.12));
302 assert_eq!(result.average_rate, dec!(1.08));
303 }
304
305 #[test]
306 fn test_rate_types_assigned_correctly() {
307 let table = make_rate_table();
308 let result = FunctionalCurrencyTranslator::translate(
309 "1200",
310 "EUR",
311 "USD",
312 "2024-12",
313 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
314 dec!(1_000_000),
315 &table,
316 );
317
318 for item in &result.translated_items {
319 match item.account_type.as_str() {
320 "Revenue" | "Expense" => {
321 assert_eq!(
322 item.rate_type,
323 TranslationRateType::AverageRate,
324 "P&L account {} should use average rate",
325 item.account
326 );
327 }
328 "Asset" | "Liability"
329 if item.account.starts_with('1')
330 && ["1000", "1100"].contains(&item.account.as_str()) =>
331 {
332 assert_eq!(
333 item.rate_type,
334 TranslationRateType::ClosingRate,
335 "Monetary BS account {} should use closing rate",
336 item.account
337 );
338 }
339 "Equity" => {
340 assert_eq!(
341 item.rate_type,
342 TranslationRateType::HistoricalRate,
343 "Equity account {} should use historical rate",
344 item.account
345 );
346 }
347 _ => {} }
349 }
350 }
351}