Skip to main content

datasynth_generators/fx/
currency_translator.rs

1//! Currency translation for financial statements.
2//!
3//! Translates trial balances and financial statements from local currency
4//! to group reporting currency using appropriate translation methods.
5
6use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use std::collections::HashMap;
10
11use datasynth_core::models::balance::TrialBalance;
12use datasynth_core::models::{
13    FxRateTable, RateType, TranslatedAmount, TranslationAccountType, TranslationMethod,
14};
15
16/// Configuration for currency translation.
17#[derive(Debug, Clone)]
18pub struct CurrencyTranslatorConfig {
19    /// Translation method to use.
20    pub method: TranslationMethod,
21    /// Group (reporting) currency.
22    pub group_currency: String,
23    /// Account type mappings (account code prefix -> translation account type).
24    pub account_type_map: HashMap<String, TranslationAccountType>,
25    /// Equity accounts that use historical rates.
26    pub historical_rate_accounts: Vec<String>,
27    /// Retained earnings account code.
28    pub retained_earnings_account: String,
29    /// CTA (Currency Translation Adjustment) account code.
30    pub cta_account: String,
31}
32
33impl Default for CurrencyTranslatorConfig {
34    fn default() -> Self {
35        let mut account_type_map = HashMap::new();
36        // Assets
37        account_type_map.insert("1".to_string(), TranslationAccountType::Asset);
38        // Liabilities
39        account_type_map.insert("2".to_string(), TranslationAccountType::Liability);
40        // Equity
41        account_type_map.insert("3".to_string(), TranslationAccountType::Equity);
42        // Revenue
43        account_type_map.insert("4".to_string(), TranslationAccountType::Revenue);
44        // Expenses
45        account_type_map.insert("5".to_string(), TranslationAccountType::Expense);
46        account_type_map.insert("6".to_string(), TranslationAccountType::Expense);
47
48        Self {
49            method: TranslationMethod::CurrentRate,
50            group_currency: "USD".to_string(),
51            account_type_map,
52            historical_rate_accounts: vec![
53                "3100".to_string(), // Common Stock
54                "3200".to_string(), // APIC
55            ],
56            retained_earnings_account: "3300".to_string(),
57            cta_account: "3900".to_string(),
58        }
59    }
60}
61
62/// Currency translator for financial statements.
63pub struct CurrencyTranslator {
64    config: CurrencyTranslatorConfig,
65}
66
67impl CurrencyTranslator {
68    /// Creates a new currency translator.
69    pub fn new(config: CurrencyTranslatorConfig) -> Self {
70        Self { config }
71    }
72
73    /// Translates a trial balance from local to group currency.
74    pub fn translate_trial_balance(
75        &self,
76        trial_balance: &TrialBalance,
77        rate_table: &FxRateTable,
78        historical_rates: &HashMap<String, Decimal>,
79    ) -> TranslatedTrialBalance {
80        let local_currency = &trial_balance.currency;
81        let period_end = trial_balance.as_of_date;
82
83        // Get closing and average rates
84        let closing_rate = rate_table
85            .get_closing_rate(local_currency, &self.config.group_currency, period_end)
86            .map(|r| r.rate)
87            .unwrap_or(Decimal::ONE);
88
89        let average_rate = rate_table
90            .get_average_rate(local_currency, &self.config.group_currency, period_end)
91            .map(|r| r.rate)
92            .unwrap_or(closing_rate);
93
94        let mut translated_lines = Vec::new();
95        let mut total_local_debit = Decimal::ZERO;
96        let mut total_local_credit = Decimal::ZERO;
97        let mut total_group_debit = Decimal::ZERO;
98        let mut total_group_credit = Decimal::ZERO;
99
100        for line in &trial_balance.lines {
101            let account_type = self.determine_account_type(&line.account_code);
102            let rate = self.determine_rate(
103                &line.account_code,
104                &account_type,
105                closing_rate,
106                average_rate,
107                historical_rates,
108            );
109
110            let group_debit = (line.debit_balance * rate).round_dp(2);
111            let group_credit = (line.credit_balance * rate).round_dp(2);
112
113            translated_lines.push(TranslatedTrialBalanceLine {
114                account_code: line.account_code.clone(),
115                account_description: Some(line.account_description.clone()),
116                account_type: account_type.clone(),
117                local_debit: line.debit_balance,
118                local_credit: line.credit_balance,
119                rate_used: rate,
120                rate_type: self.rate_type_for_account(&account_type),
121                group_debit,
122                group_credit,
123            });
124
125            total_local_debit += line.debit_balance;
126            total_local_credit += line.credit_balance;
127            total_group_debit += group_debit;
128            total_group_credit += group_credit;
129        }
130
131        // Calculate CTA to balance the translated trial balance
132        let cta_amount = total_group_debit - total_group_credit;
133
134        TranslatedTrialBalance {
135            company_code: trial_balance.company_code.clone(),
136            company_name: trial_balance.company_name.clone().unwrap_or_default(),
137            local_currency: local_currency.clone(),
138            group_currency: self.config.group_currency.clone(),
139            period_end_date: period_end,
140            fiscal_year: trial_balance.fiscal_year,
141            fiscal_period: trial_balance.fiscal_period as u8,
142            lines: translated_lines,
143            closing_rate,
144            average_rate,
145            total_local_debit,
146            total_local_credit,
147            total_group_debit,
148            total_group_credit,
149            cta_amount,
150            translation_method: self.config.method.clone(),
151        }
152    }
153
154    /// Translates a single amount.
155    pub fn translate_amount(
156        &self,
157        amount: Decimal,
158        local_currency: &str,
159        account_type: &TranslationAccountType,
160        rate_table: &FxRateTable,
161        date: NaiveDate,
162    ) -> TranslatedAmount {
163        let (rate, rate_type) = match account_type {
164            TranslationAccountType::Asset | TranslationAccountType::Liability => {
165                let rate = rate_table
166                    .get_closing_rate(local_currency, &self.config.group_currency, date)
167                    .map(|r| r.rate)
168                    .unwrap_or(Decimal::ONE);
169                (rate, RateType::Closing)
170            }
171            TranslationAccountType::Revenue | TranslationAccountType::Expense => {
172                let rate = rate_table
173                    .get_average_rate(local_currency, &self.config.group_currency, date)
174                    .map(|r| r.rate)
175                    .unwrap_or(Decimal::ONE);
176                (rate, RateType::Average)
177            }
178            TranslationAccountType::Equity
179            | TranslationAccountType::CommonStock
180            | TranslationAccountType::AdditionalPaidInCapital => {
181                (Decimal::ONE, RateType::Historical) // Would need actual historical rate
182            }
183            TranslationAccountType::RetainedEarnings => {
184                // Retained earnings is a plug figure
185                (Decimal::ONE, RateType::Historical)
186            }
187        };
188
189        TranslatedAmount {
190            local_amount: amount,
191            local_currency: local_currency.to_string(),
192            group_amount: (amount * rate).round_dp(2),
193            group_currency: self.config.group_currency.clone(),
194            rate_used: rate,
195            rate_type,
196            translation_date: date,
197        }
198    }
199
200    /// Determines the account type based on account code.
201    fn determine_account_type(&self, account_code: &str) -> TranslationAccountType {
202        // Check for specific accounts first
203        if self
204            .config
205            .historical_rate_accounts
206            .contains(&account_code.to_string())
207        {
208            if account_code.starts_with("31") {
209                return TranslationAccountType::CommonStock;
210            } else if account_code.starts_with("32") {
211                return TranslationAccountType::AdditionalPaidInCapital;
212            }
213        }
214
215        if account_code == self.config.retained_earnings_account {
216            return TranslationAccountType::RetainedEarnings;
217        }
218
219        // Use prefix mapping
220        for (prefix, account_type) in &self.config.account_type_map {
221            if account_code.starts_with(prefix) {
222                return account_type.clone();
223            }
224        }
225
226        // Default to asset
227        TranslationAccountType::Asset
228    }
229
230    /// Determines the appropriate rate to use for an account.
231    fn determine_rate(
232        &self,
233        account_code: &str,
234        account_type: &TranslationAccountType,
235        closing_rate: Decimal,
236        average_rate: Decimal,
237        historical_rates: &HashMap<String, Decimal>,
238    ) -> Decimal {
239        match self.config.method {
240            TranslationMethod::CurrentRate => {
241                match account_type {
242                    TranslationAccountType::Asset | TranslationAccountType::Liability => {
243                        closing_rate
244                    }
245                    TranslationAccountType::Revenue | TranslationAccountType::Expense => {
246                        average_rate
247                    }
248                    TranslationAccountType::CommonStock
249                    | TranslationAccountType::AdditionalPaidInCapital => {
250                        // Use historical rate if available
251                        historical_rates
252                            .get(account_code)
253                            .copied()
254                            .unwrap_or(closing_rate)
255                    }
256                    TranslationAccountType::Equity | TranslationAccountType::RetainedEarnings => {
257                        // These are typically calculated separately
258                        closing_rate
259                    }
260                }
261            }
262            TranslationMethod::Temporal => {
263                // Temporal method: monetary items at closing, non-monetary at historical
264                match account_type {
265                    TranslationAccountType::Asset => {
266                        // Would need to distinguish monetary vs non-monetary
267                        // For simplicity, using closing rate
268                        closing_rate
269                    }
270                    TranslationAccountType::Liability => closing_rate,
271                    _ => average_rate,
272                }
273            }
274            TranslationMethod::MonetaryNonMonetary => closing_rate, // Simplified
275        }
276    }
277
278    /// Returns the rate type for a given account type.
279    fn rate_type_for_account(&self, account_type: &TranslationAccountType) -> RateType {
280        match account_type {
281            TranslationAccountType::Asset | TranslationAccountType::Liability => RateType::Closing,
282            TranslationAccountType::Revenue | TranslationAccountType::Expense => RateType::Average,
283            TranslationAccountType::Equity
284            | TranslationAccountType::CommonStock
285            | TranslationAccountType::AdditionalPaidInCapital
286            | TranslationAccountType::RetainedEarnings => RateType::Historical,
287        }
288    }
289}
290
291/// Translated trial balance in group currency.
292#[derive(Debug, Clone)]
293pub struct TranslatedTrialBalance {
294    /// Company code.
295    pub company_code: String,
296    /// Company name.
297    pub company_name: String,
298    /// Local (functional) currency.
299    pub local_currency: String,
300    /// Group (reporting) currency.
301    pub group_currency: String,
302    /// Period end date.
303    pub period_end_date: NaiveDate,
304    /// Fiscal year.
305    pub fiscal_year: i32,
306    /// Fiscal period.
307    pub fiscal_period: u8,
308    /// Translated line items.
309    pub lines: Vec<TranslatedTrialBalanceLine>,
310    /// Closing rate used.
311    pub closing_rate: Decimal,
312    /// Average rate used.
313    pub average_rate: Decimal,
314    /// Total local currency debits.
315    pub total_local_debit: Decimal,
316    /// Total local currency credits.
317    pub total_local_credit: Decimal,
318    /// Total group currency debits.
319    pub total_group_debit: Decimal,
320    /// Total group currency credits.
321    pub total_group_credit: Decimal,
322    /// Currency Translation Adjustment amount.
323    pub cta_amount: Decimal,
324    /// Translation method used.
325    pub translation_method: TranslationMethod,
326}
327
328impl TranslatedTrialBalance {
329    /// Returns true if the local currency trial balance is balanced.
330    pub fn is_local_balanced(&self) -> bool {
331        (self.total_local_debit - self.total_local_credit).abs() < dec!(0.01)
332    }
333
334    /// Returns true if the group currency trial balance is balanced (including CTA).
335    pub fn is_group_balanced(&self) -> bool {
336        let balance = self.total_group_debit - self.total_group_credit - self.cta_amount;
337        balance.abs() < dec!(0.01)
338    }
339
340    /// Gets the net assets in local currency.
341    pub fn local_net_assets(&self) -> Decimal {
342        let assets: Decimal = self
343            .lines
344            .iter()
345            .filter(|l| matches!(l.account_type, TranslationAccountType::Asset))
346            .map(|l| l.local_debit - l.local_credit)
347            .sum();
348
349        let liabilities: Decimal = self
350            .lines
351            .iter()
352            .filter(|l| matches!(l.account_type, TranslationAccountType::Liability))
353            .map(|l| l.local_credit - l.local_debit)
354            .sum();
355
356        assets - liabilities
357    }
358
359    /// Gets the net assets in group currency.
360    pub fn group_net_assets(&self) -> Decimal {
361        let assets: Decimal = self
362            .lines
363            .iter()
364            .filter(|l| matches!(l.account_type, TranslationAccountType::Asset))
365            .map(|l| l.group_debit - l.group_credit)
366            .sum();
367
368        let liabilities: Decimal = self
369            .lines
370            .iter()
371            .filter(|l| matches!(l.account_type, TranslationAccountType::Liability))
372            .map(|l| l.group_credit - l.group_debit)
373            .sum();
374
375        assets - liabilities
376    }
377}
378
379/// A line in a translated trial balance.
380#[derive(Debug, Clone)]
381pub struct TranslatedTrialBalanceLine {
382    /// Account code.
383    pub account_code: String,
384    /// Account description.
385    pub account_description: Option<String>,
386    /// Account type for translation.
387    pub account_type: TranslationAccountType,
388    /// Debit balance in local currency.
389    pub local_debit: Decimal,
390    /// Credit balance in local currency.
391    pub local_credit: Decimal,
392    /// Exchange rate used.
393    pub rate_used: Decimal,
394    /// Rate type used.
395    pub rate_type: RateType,
396    /// Debit balance in group currency.
397    pub group_debit: Decimal,
398    /// Credit balance in group currency.
399    pub group_credit: Decimal,
400}
401
402impl TranslatedTrialBalanceLine {
403    /// Gets the net balance in local currency.
404    pub fn local_net(&self) -> Decimal {
405        self.local_debit - self.local_credit
406    }
407
408    /// Gets the net balance in group currency.
409    pub fn group_net(&self) -> Decimal {
410        self.group_debit - self.group_credit
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use datasynth_core::models::balance::{
418        AccountCategory, AccountType, TrialBalanceLine, TrialBalanceType,
419    };
420    use datasynth_core::models::FxRate;
421
422    fn create_test_trial_balance() -> TrialBalance {
423        let mut tb = TrialBalance::new(
424            "TB-TEST-2024-12".to_string(),
425            "1200".to_string(),
426            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
427            2024,
428            12,
429            "EUR".to_string(),
430            TrialBalanceType::PostClosing,
431        );
432        tb.company_name = Some("Test Subsidiary".to_string());
433
434        tb.add_line(TrialBalanceLine {
435            account_code: "1000".to_string(),
436            account_description: "Cash".to_string(),
437            category: AccountCategory::CurrentAssets,
438            account_type: AccountType::Asset,
439            opening_balance: Decimal::ZERO,
440            period_debits: dec!(100000),
441            period_credits: Decimal::ZERO,
442            closing_balance: dec!(100000),
443            debit_balance: dec!(100000),
444            credit_balance: Decimal::ZERO,
445            cost_center: None,
446            profit_center: None,
447        });
448
449        tb.add_line(TrialBalanceLine {
450            account_code: "2000".to_string(),
451            account_description: "Accounts Payable".to_string(),
452            category: AccountCategory::CurrentLiabilities,
453            account_type: AccountType::Liability,
454            opening_balance: Decimal::ZERO,
455            period_debits: Decimal::ZERO,
456            period_credits: dec!(50000),
457            closing_balance: dec!(50000),
458            debit_balance: Decimal::ZERO,
459            credit_balance: dec!(50000),
460            cost_center: None,
461            profit_center: None,
462        });
463
464        tb.add_line(TrialBalanceLine {
465            account_code: "4000".to_string(),
466            account_description: "Revenue".to_string(),
467            category: AccountCategory::Revenue,
468            account_type: AccountType::Revenue,
469            opening_balance: Decimal::ZERO,
470            period_debits: Decimal::ZERO,
471            period_credits: dec!(150000),
472            closing_balance: dec!(150000),
473            debit_balance: Decimal::ZERO,
474            credit_balance: dec!(150000),
475            cost_center: None,
476            profit_center: None,
477        });
478
479        tb.add_line(TrialBalanceLine {
480            account_code: "5000".to_string(),
481            account_description: "Expenses".to_string(),
482            category: AccountCategory::OperatingExpenses,
483            account_type: AccountType::Expense,
484            opening_balance: Decimal::ZERO,
485            period_debits: dec!(100000),
486            period_credits: Decimal::ZERO,
487            closing_balance: dec!(100000),
488            debit_balance: dec!(100000),
489            credit_balance: Decimal::ZERO,
490            cost_center: None,
491            profit_center: None,
492        });
493
494        tb
495    }
496
497    #[test]
498    fn test_translate_trial_balance() {
499        let translator = CurrencyTranslator::new(CurrencyTranslatorConfig::default());
500        let trial_balance = create_test_trial_balance();
501
502        let mut rate_table = FxRateTable::new("USD");
503        rate_table.add_rate(FxRate::new(
504            "EUR",
505            "USD",
506            RateType::Closing,
507            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
508            dec!(1.10),
509            "TEST",
510        ));
511        rate_table.add_rate(FxRate::new(
512            "EUR",
513            "USD",
514            RateType::Average,
515            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
516            dec!(1.08),
517            "TEST",
518        ));
519
520        let historical_rates = HashMap::new();
521        let translated =
522            translator.translate_trial_balance(&trial_balance, &rate_table, &historical_rates);
523
524        assert!(translated.is_local_balanced());
525        assert_eq!(translated.closing_rate, dec!(1.10));
526        assert_eq!(translated.average_rate, dec!(1.08));
527    }
528}