Skip to main content

datasynth_generators/fx/
functional_currency_translator.rs

1//! IAS 21 functional currency translation generator.
2//!
3//! Produces [`CurrencyTranslationResult`] records for entities whose functional
4//! currency differs from the group presentation currency.  The current-rate
5//! method (most common for foreign operations) is used by default:
6//!
7//! | Item type                       | Rate applied      |
8//! |---------------------------------|-------------------|
9//! | BS monetary (cash, AR, AP …)    | closing rate      |
10//! | BS non-monetary (PP&E, inventory, equity) | historical rate |
11//! | P&L (revenue, expenses)         | average rate      |
12//!
13//! The **Currency Translation Adjustment (CTA)** is the balancing amount
14//! between "all BS items at closing rate" and "mixed-rate translated BS"
15//! — recognised in Other Comprehensive Income.
16
17use 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
26// ---------------------------------------------------------------------------
27// Synthetic account structure used when no trial-balance data is provided.
28// Each entry is (account_code, account_type_label, is_monetary_bs, is_pnl,
29//               functional_amount_factor).
30// ---------------------------------------------------------------------------
31
32/// Synthetic balance-sheet / P&L structure used when no real trial balance
33/// is provided.  Amounts are scaled from a `revenue_proxy` so that the
34/// generated data is internally consistent.
35static SYNTHETIC_ACCOUNTS: &[(&str, &str, bool, bool, f64)] = &[
36    // (code, type_label, is_monetary_bs, is_pnl, amount_factor_of_revenue)
37    // --- Balance sheet — monetary ---
38    ("1000", "Asset", true, false, 0.20),      // Cash
39    ("1100", "Asset", true, false, 0.30),      // Accounts receivable
40    ("2000", "Liability", true, false, -0.25), // Accounts payable
41    ("2100", "Liability", true, false, -0.10), // Accrued liabilities
42    // --- Balance sheet — non-monetary ---
43    ("1500", "Asset", false, false, 0.15), // Inventory (at cost)
44    ("1600", "Asset", false, false, 0.40), // PP&E (net)
45    ("3100", "Equity", false, false, -0.50), // Common stock (historical)
46    ("3300", "Equity", false, false, -0.20), // Retained earnings
47    // --- P&L ---
48    ("4000", "Revenue", false, true, 1.00),  // Revenue
49    ("5000", "Expense", false, true, -0.60), // COGS
50    ("6000", "Expense", false, true, -0.25), // Operating expenses
51];
52
53/// Generator for IAS 21 functional-currency translations.
54pub struct FunctionalCurrencyTranslator;
55
56impl FunctionalCurrencyTranslator {
57    /// Translate a single entity for one reporting period.
58    ///
59    /// # Parameters
60    /// - `entity_code`            — company / entity identifier
61    /// - `functional_currency`    — entity's functional currency (ISO 4217)
62    /// - `presentation_currency`  — group presentation currency (ISO 4217)
63    /// - `period_label`           — human-readable period, e.g. "2024-12"
64    /// - `period_end`             — last day of the period
65    /// - `revenue_proxy`          — scale for synthetic amounts (functional currency)
66    /// - `rate_table`             — pre-populated [`FxRateTable`]
67    ///
68    /// Returns `None` when functional == presentation (no translation needed;
69    /// a zero-CTA result is still constructed for completeness).
70    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        // Same-currency: no translation required.
80        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        // Retrieve rates.
91        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        // Use a representative "historical" rate slightly different from
102        // closing to model equity accounts that were recorded at inception.
103        // In practice this would come from the original transaction dates;
104        // here we approximate as 95% of the closing rate.
105        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                // Fallback: use 95% of closing as a reasonable historical proxy.
115                (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                // Non-monetary BS (inventory at cost, PP&E, equity)
134                (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        // CTA = BS if all translated at closing − BS translated at mixed rates
158        //
159        // The "all-at-closing" method would give:
160        //   total_bs_functional × closing_rate
161        // The actual mixed-rate translation gives:
162        //   total_bs_presentation
163        //
164        // CTA = all_closing − mixed
165        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    /// Build a no-op result when functional currency equals presentation currency.
186    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, // same
229            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        // CTA should be non-zero because closing != historical rate
300        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                _ => {} // other accounts: skip
348            }
349        }
350    }
351}