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)]
236mod tests {
237 use super::*;
238 use datasynth_core::models::{FxRate, FxRateTable, RateType};
239 use rust_decimal_macros::dec;
240
241 fn make_rate_table() -> FxRateTable {
242 let mut table = FxRateTable::new("USD");
243 let period_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
244
245 table.add_rate(FxRate::new(
246 "EUR",
247 "USD",
248 RateType::Closing,
249 period_end,
250 dec!(1.12),
251 "TEST",
252 ));
253 table.add_rate(FxRate::new(
254 "EUR",
255 "USD",
256 RateType::Average,
257 period_end,
258 dec!(1.08),
259 "TEST",
260 ));
261 table
262 }
263
264 #[test]
265 fn test_same_currency_no_translation() {
266 let table = make_rate_table();
267 let result = FunctionalCurrencyTranslator::translate(
268 "1000",
269 "USD",
270 "USD",
271 "2024-12",
272 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
273 dec!(1_000_000),
274 &table,
275 );
276
277 assert_eq!(result.cta_amount, Decimal::ZERO);
278 assert_eq!(result.closing_rate, Decimal::ONE);
279 assert!(result
280 .translated_items
281 .iter()
282 .all(|i| i.rate_type == TranslationRateType::NoTranslation));
283 }
284
285 #[test]
286 fn test_different_currency_cta_non_zero() {
287 let table = make_rate_table();
288 let result = FunctionalCurrencyTranslator::translate(
289 "1200",
290 "EUR",
291 "USD",
292 "2024-12",
293 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
294 dec!(1_000_000),
295 &table,
296 );
297
298 assert_ne!(result.cta_amount, Decimal::ZERO);
300 assert_eq!(result.closing_rate, dec!(1.12));
301 assert_eq!(result.average_rate, dec!(1.08));
302 }
303
304 #[test]
305 fn test_rate_types_assigned_correctly() {
306 let table = make_rate_table();
307 let result = FunctionalCurrencyTranslator::translate(
308 "1200",
309 "EUR",
310 "USD",
311 "2024-12",
312 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
313 dec!(1_000_000),
314 &table,
315 );
316
317 for item in &result.translated_items {
318 match item.account_type.as_str() {
319 "Revenue" | "Expense" => {
320 assert_eq!(
321 item.rate_type,
322 TranslationRateType::AverageRate,
323 "P&L account {} should use average rate",
324 item.account
325 );
326 }
327 "Asset" | "Liability"
328 if item.account.starts_with('1')
329 && ["1000", "1100"].contains(&item.account.as_str()) =>
330 {
331 assert_eq!(
332 item.rate_type,
333 TranslationRateType::ClosingRate,
334 "Monetary BS account {} should use closing rate",
335 item.account
336 );
337 }
338 "Equity" => {
339 assert_eq!(
340 item.rate_type,
341 TranslationRateType::HistoricalRate,
342 "Equity account {} should use historical rate",
343 item.account
344 );
345 }
346 _ => {} }
348 }
349 }
350}