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/// Classifies whether an account is monetary based on its account code prefix.
63///
64/// Monetary items include cash, receivables, payables, and other items that
65/// are settled in fixed currency amounts. Non-monetary items include inventory,
66/// PP&E, intangibles, equity, and other items whose value fluctuates.
67///
68/// Classification by 2-digit prefix ranges:
69/// - 10xx (Cash & Cash Equivalents): Monetary
70/// - 11xx (Accounts Receivable): Monetary
71/// - 12xx (Short-term Investments): Monetary
72/// - 13xx (Notes Receivable): Monetary
73/// - 14xx (Prepaid Expenses): Non-monetary (future economic benefit)
74/// - 15xx (Inventory): Non-monetary
75/// - 16xx (Property, Plant & Equipment): Non-monetary
76/// - 17xx (Intangible Assets): Non-monetary
77/// - 18xx (Long-term Investments): Non-monetary
78/// - 19xx (Other Non-current Assets): Non-monetary
79/// - 20xx-29xx (Liabilities): Monetary (obligations settled in cash)
80/// - 30xx-39xx (Equity): Non-monetary
81/// - 40xx-49xx (Revenue): Treated as monetary for temporal method
82/// - 50xx-69xx (Expenses): Treated as monetary for temporal method
83pub fn is_monetary(account_code: &str) -> bool {
84    if account_code.len() < 2 {
85        // Default to monetary for very short codes
86        return true;
87    }
88
89    let prefix2 = &account_code[..2];
90
91    match prefix2 {
92        // Cash and cash equivalents - monetary
93        "10" => true,
94        // Accounts receivable - monetary
95        "11" => true,
96        // Short-term investments / marketable securities - monetary
97        "12" => true,
98        // Notes receivable - monetary
99        "13" => true,
100        // Prepaid expenses - non-monetary (future benefit, not cash settlement)
101        "14" => false,
102        // Inventory - non-monetary
103        "15" => false,
104        // Property, plant & equipment - non-monetary
105        "16" => false,
106        // Intangible assets - non-monetary
107        "17" => false,
108        // Long-term investments - non-monetary
109        "18" => false,
110        // Other non-current assets - non-monetary
111        "19" => false,
112        // All liabilities (20xx-29xx) - monetary (obligations to pay cash)
113        "20" | "21" | "22" | "23" | "24" | "25" | "26" | "27" | "28" | "29" => true,
114        // All equity accounts (30xx-39xx) - non-monetary
115        "30" | "31" | "32" | "33" | "34" | "35" | "36" | "37" | "38" | "39" => false,
116        // Revenue (40xx-49xx) - income statement items use average rate
117        "40" | "41" | "42" | "43" | "44" | "45" | "46" | "47" | "48" | "49" => true,
118        // Expenses (50xx-69xx) - income statement items use average rate
119        "50" | "51" | "52" | "53" | "54" | "55" | "56" | "57" | "58" | "59" => true,
120        "60" | "61" | "62" | "63" | "64" | "65" | "66" | "67" | "68" | "69" => true,
121        // Default: treat as monetary (conservative approach)
122        _ => true,
123    }
124}
125
126/// Currency translator for financial statements.
127pub struct CurrencyTranslator {
128    config: CurrencyTranslatorConfig,
129    /// Historical equity rates keyed by account code.
130    historical_equity_rates: HashMap<String, Decimal>,
131}
132
133impl CurrencyTranslator {
134    /// Creates a new currency translator.
135    pub fn new(config: CurrencyTranslatorConfig) -> Self {
136        Self {
137            config,
138            historical_equity_rates: HashMap::new(),
139        }
140    }
141
142    /// Sets historical equity rates for specific accounts.
143    ///
144    /// These rates are used when translating equity accounts under the
145    /// Temporal or MonetaryNonMonetary methods. The rates represent
146    /// the exchange rate at the time equity transactions originally occurred.
147    pub fn set_historical_equity_rates(&mut self, rates: HashMap<String, Decimal>) {
148        self.historical_equity_rates = rates;
149    }
150
151    /// Translates a trial balance from local to group currency.
152    pub fn translate_trial_balance(
153        &self,
154        trial_balance: &TrialBalance,
155        rate_table: &FxRateTable,
156        historical_rates: &HashMap<String, Decimal>,
157    ) -> TranslatedTrialBalance {
158        let local_currency = &trial_balance.currency;
159        let period_end = trial_balance.as_of_date;
160
161        // Get closing and average rates
162        let closing_rate = rate_table
163            .get_closing_rate(local_currency, &self.config.group_currency, period_end)
164            .map(|r| r.rate)
165            .unwrap_or(Decimal::ONE);
166
167        let average_rate = rate_table
168            .get_average_rate(local_currency, &self.config.group_currency, period_end)
169            .map(|r| r.rate)
170            .unwrap_or(closing_rate);
171
172        let mut translated_lines = Vec::new();
173        let mut total_local_debit = Decimal::ZERO;
174        let mut total_local_credit = Decimal::ZERO;
175        let mut total_group_debit = Decimal::ZERO;
176        let mut total_group_credit = Decimal::ZERO;
177
178        for line in &trial_balance.lines {
179            let account_type = self.determine_account_type(&line.account_code);
180            let rate = self.determine_rate(
181                &line.account_code,
182                &account_type,
183                closing_rate,
184                average_rate,
185                historical_rates,
186            );
187
188            let group_debit = (line.debit_balance * rate).round_dp(2);
189            let group_credit = (line.credit_balance * rate).round_dp(2);
190
191            translated_lines.push(TranslatedTrialBalanceLine {
192                account_code: line.account_code.clone(),
193                account_description: Some(line.account_description.clone()),
194                account_type: account_type.clone(),
195                local_debit: line.debit_balance,
196                local_credit: line.credit_balance,
197                rate_used: rate,
198                rate_type: self.rate_type_for_account(&account_type),
199                group_debit,
200                group_credit,
201            });
202
203            total_local_debit += line.debit_balance;
204            total_local_credit += line.credit_balance;
205            total_group_debit += group_debit;
206            total_group_credit += group_credit;
207        }
208
209        // Calculate CTA to balance the translated trial balance
210        let cta_amount = total_group_debit - total_group_credit;
211
212        TranslatedTrialBalance {
213            company_code: trial_balance.company_code.clone(),
214            company_name: trial_balance.company_name.clone().unwrap_or_default(),
215            local_currency: local_currency.clone(),
216            group_currency: self.config.group_currency.clone(),
217            period_end_date: period_end,
218            fiscal_year: trial_balance.fiscal_year,
219            fiscal_period: trial_balance.fiscal_period as u8,
220            lines: translated_lines,
221            closing_rate,
222            average_rate,
223            total_local_debit,
224            total_local_credit,
225            total_group_debit,
226            total_group_credit,
227            cta_amount,
228            translation_method: self.config.method.clone(),
229        }
230    }
231
232    /// Translates a single amount.
233    ///
234    /// For equity accounts, this method will use historical equity rates set
235    /// via [`set_historical_equity_rates`] if available, falling back to
236    /// `Decimal::ONE` when no historical rate is found.
237    pub fn translate_amount(
238        &self,
239        amount: Decimal,
240        local_currency: &str,
241        account_code: &str,
242        account_type: &TranslationAccountType,
243        rate_table: &FxRateTable,
244        date: NaiveDate,
245    ) -> TranslatedAmount {
246        let closing_rate = rate_table
247            .get_closing_rate(local_currency, &self.config.group_currency, date)
248            .map(|r| r.rate)
249            .unwrap_or(Decimal::ONE);
250
251        let average_rate = rate_table
252            .get_average_rate(local_currency, &self.config.group_currency, date)
253            .map(|r| r.rate)
254            .unwrap_or(closing_rate);
255
256        let (rate, rate_type) = match &self.config.method {
257            TranslationMethod::CurrentRate => match account_type {
258                TranslationAccountType::Asset | TranslationAccountType::Liability => {
259                    (closing_rate, RateType::Closing)
260                }
261                TranslationAccountType::Revenue | TranslationAccountType::Expense => {
262                    (average_rate, RateType::Average)
263                }
264                TranslationAccountType::Equity
265                | TranslationAccountType::CommonStock
266                | TranslationAccountType::AdditionalPaidInCapital
267                | TranslationAccountType::RetainedEarnings => {
268                    let hist_rate = self
269                        .historical_equity_rates
270                        .get(account_code)
271                        .copied()
272                        .unwrap_or(Decimal::ONE);
273                    (hist_rate, RateType::Historical)
274                }
275            },
276            TranslationMethod::Temporal => match account_type {
277                TranslationAccountType::Revenue | TranslationAccountType::Expense => {
278                    (average_rate, RateType::Average)
279                }
280                TranslationAccountType::CommonStock
281                | TranslationAccountType::AdditionalPaidInCapital
282                | TranslationAccountType::RetainedEarnings
283                | TranslationAccountType::Equity => {
284                    let hist_rate = self
285                        .historical_equity_rates
286                        .get(account_code)
287                        .copied()
288                        .unwrap_or(Decimal::ONE);
289                    (hist_rate, RateType::Historical)
290                }
291                TranslationAccountType::Asset | TranslationAccountType::Liability => {
292                    if is_monetary(account_code) {
293                        (closing_rate, RateType::Closing)
294                    } else {
295                        let hist_rate = self
296                            .historical_equity_rates
297                            .get(account_code)
298                            .copied()
299                            .unwrap_or(closing_rate);
300                        (hist_rate, RateType::Historical)
301                    }
302                }
303            },
304            TranslationMethod::MonetaryNonMonetary => match account_type {
305                TranslationAccountType::CommonStock
306                | TranslationAccountType::AdditionalPaidInCapital
307                | TranslationAccountType::RetainedEarnings
308                | TranslationAccountType::Equity => {
309                    let hist_rate = self
310                        .historical_equity_rates
311                        .get(account_code)
312                        .copied()
313                        .unwrap_or(Decimal::ONE);
314                    (hist_rate, RateType::Historical)
315                }
316                _ => {
317                    if is_monetary(account_code) {
318                        (closing_rate, RateType::Closing)
319                    } else {
320                        let hist_rate = self
321                            .historical_equity_rates
322                            .get(account_code)
323                            .copied()
324                            .unwrap_or(closing_rate);
325                        (hist_rate, RateType::Historical)
326                    }
327                }
328            },
329        };
330
331        TranslatedAmount {
332            local_amount: amount,
333            local_currency: local_currency.to_string(),
334            group_amount: (amount * rate).round_dp(2),
335            group_currency: self.config.group_currency.clone(),
336            rate_used: rate,
337            rate_type,
338            translation_date: date,
339        }
340    }
341
342    /// Determines the account type based on account code.
343    fn determine_account_type(&self, account_code: &str) -> TranslationAccountType {
344        // Check for specific accounts first
345        if self
346            .config
347            .historical_rate_accounts
348            .contains(&account_code.to_string())
349        {
350            if account_code.starts_with("31") {
351                return TranslationAccountType::CommonStock;
352            } else if account_code.starts_with("32") {
353                return TranslationAccountType::AdditionalPaidInCapital;
354            }
355        }
356
357        if account_code == self.config.retained_earnings_account {
358            return TranslationAccountType::RetainedEarnings;
359        }
360
361        // Use prefix mapping
362        for (prefix, account_type) in &self.config.account_type_map {
363            if account_code.starts_with(prefix) {
364                return account_type.clone();
365            }
366        }
367
368        // Default to asset
369        TranslationAccountType::Asset
370    }
371
372    /// Looks up a historical equity rate for a given account code.
373    ///
374    /// Checks both the instance-level `historical_equity_rates` and the
375    /// passed-in `historical_rates` parameter. Instance-level rates take precedence.
376    fn lookup_historical_equity_rate(
377        &self,
378        account_code: &str,
379        historical_rates: &HashMap<String, Decimal>,
380        fallback: Decimal,
381    ) -> Decimal {
382        self.historical_equity_rates
383            .get(account_code)
384            .or_else(|| historical_rates.get(account_code))
385            .copied()
386            .unwrap_or(fallback)
387    }
388
389    /// Determines the appropriate rate to use for an account.
390    fn determine_rate(
391        &self,
392        account_code: &str,
393        account_type: &TranslationAccountType,
394        closing_rate: Decimal,
395        average_rate: Decimal,
396        historical_rates: &HashMap<String, Decimal>,
397    ) -> Decimal {
398        match self.config.method {
399            TranslationMethod::CurrentRate => {
400                match account_type {
401                    TranslationAccountType::Asset | TranslationAccountType::Liability => {
402                        closing_rate
403                    }
404                    TranslationAccountType::Revenue | TranslationAccountType::Expense => {
405                        average_rate
406                    }
407                    TranslationAccountType::CommonStock
408                    | TranslationAccountType::AdditionalPaidInCapital => {
409                        // Use historical rate if available
410                        self.lookup_historical_equity_rate(
411                            account_code,
412                            historical_rates,
413                            closing_rate,
414                        )
415                    }
416                    TranslationAccountType::Equity | TranslationAccountType::RetainedEarnings => {
417                        // These are typically calculated separately
418                        closing_rate
419                    }
420                }
421            }
422            TranslationMethod::Temporal => {
423                // Temporal method: monetary items at closing rate, non-monetary at historical rate.
424                // Equity accounts always use historical rates.
425                match account_type {
426                    TranslationAccountType::CommonStock
427                    | TranslationAccountType::AdditionalPaidInCapital
428                    | TranslationAccountType::RetainedEarnings => self
429                        .lookup_historical_equity_rate(
430                            account_code,
431                            historical_rates,
432                            closing_rate,
433                        ),
434                    TranslationAccountType::Equity => self.lookup_historical_equity_rate(
435                        account_code,
436                        historical_rates,
437                        closing_rate,
438                    ),
439                    TranslationAccountType::Revenue | TranslationAccountType::Expense => {
440                        // Income statement items use average rate under temporal method
441                        average_rate
442                    }
443                    TranslationAccountType::Asset | TranslationAccountType::Liability => {
444                        // Distinguish monetary vs non-monetary for balance sheet items
445                        if is_monetary(account_code) {
446                            closing_rate
447                        } else {
448                            // Non-monetary items use historical rate if available,
449                            // otherwise fall back to closing rate
450                            historical_rates
451                                .get(account_code)
452                                .copied()
453                                .unwrap_or(closing_rate)
454                        }
455                    }
456                }
457            }
458            TranslationMethod::MonetaryNonMonetary => {
459                // Monetary/Non-monetary method: similar to temporal but focused
460                // specifically on the monetary vs non-monetary distinction.
461                // Equity accounts use historical rates.
462                match account_type {
463                    TranslationAccountType::CommonStock
464                    | TranslationAccountType::AdditionalPaidInCapital
465                    | TranslationAccountType::RetainedEarnings => self
466                        .lookup_historical_equity_rate(
467                            account_code,
468                            historical_rates,
469                            closing_rate,
470                        ),
471                    TranslationAccountType::Equity => self.lookup_historical_equity_rate(
472                        account_code,
473                        historical_rates,
474                        closing_rate,
475                    ),
476                    _ => {
477                        // For all other accounts, classify based on monetary nature
478                        if is_monetary(account_code) {
479                            closing_rate
480                        } else {
481                            // Non-monetary items use historical rate if available
482                            historical_rates
483                                .get(account_code)
484                                .copied()
485                                .unwrap_or(closing_rate)
486                        }
487                    }
488                }
489            }
490        }
491    }
492
493    /// Returns the rate type for a given account type.
494    fn rate_type_for_account(&self, account_type: &TranslationAccountType) -> RateType {
495        match account_type {
496            TranslationAccountType::Asset | TranslationAccountType::Liability => RateType::Closing,
497            TranslationAccountType::Revenue | TranslationAccountType::Expense => RateType::Average,
498            TranslationAccountType::Equity
499            | TranslationAccountType::CommonStock
500            | TranslationAccountType::AdditionalPaidInCapital
501            | TranslationAccountType::RetainedEarnings => RateType::Historical,
502        }
503    }
504}
505
506/// Translated trial balance in group currency.
507#[derive(Debug, Clone)]
508pub struct TranslatedTrialBalance {
509    /// Company code.
510    pub company_code: String,
511    /// Company name.
512    pub company_name: String,
513    /// Local (functional) currency.
514    pub local_currency: String,
515    /// Group (reporting) currency.
516    pub group_currency: String,
517    /// Period end date.
518    pub period_end_date: NaiveDate,
519    /// Fiscal year.
520    pub fiscal_year: i32,
521    /// Fiscal period.
522    pub fiscal_period: u8,
523    /// Translated line items.
524    pub lines: Vec<TranslatedTrialBalanceLine>,
525    /// Closing rate used.
526    pub closing_rate: Decimal,
527    /// Average rate used.
528    pub average_rate: Decimal,
529    /// Total local currency debits.
530    pub total_local_debit: Decimal,
531    /// Total local currency credits.
532    pub total_local_credit: Decimal,
533    /// Total group currency debits.
534    pub total_group_debit: Decimal,
535    /// Total group currency credits.
536    pub total_group_credit: Decimal,
537    /// Currency Translation Adjustment amount.
538    pub cta_amount: Decimal,
539    /// Translation method used.
540    pub translation_method: TranslationMethod,
541}
542
543impl TranslatedTrialBalance {
544    /// Returns true if the local currency trial balance is balanced.
545    pub fn is_local_balanced(&self) -> bool {
546        (self.total_local_debit - self.total_local_credit).abs() < dec!(0.01)
547    }
548
549    /// Returns true if the group currency trial balance is balanced (including CTA).
550    pub fn is_group_balanced(&self) -> bool {
551        let balance = self.total_group_debit - self.total_group_credit - self.cta_amount;
552        balance.abs() < dec!(0.01)
553    }
554
555    /// Gets the net assets in local currency.
556    pub fn local_net_assets(&self) -> Decimal {
557        let assets: Decimal = self
558            .lines
559            .iter()
560            .filter(|l| matches!(l.account_type, TranslationAccountType::Asset))
561            .map(|l| l.local_debit - l.local_credit)
562            .sum();
563
564        let liabilities: Decimal = self
565            .lines
566            .iter()
567            .filter(|l| matches!(l.account_type, TranslationAccountType::Liability))
568            .map(|l| l.local_credit - l.local_debit)
569            .sum();
570
571        assets - liabilities
572    }
573
574    /// Gets the net assets in group currency.
575    pub fn group_net_assets(&self) -> Decimal {
576        let assets: Decimal = self
577            .lines
578            .iter()
579            .filter(|l| matches!(l.account_type, TranslationAccountType::Asset))
580            .map(|l| l.group_debit - l.group_credit)
581            .sum();
582
583        let liabilities: Decimal = self
584            .lines
585            .iter()
586            .filter(|l| matches!(l.account_type, TranslationAccountType::Liability))
587            .map(|l| l.group_credit - l.group_debit)
588            .sum();
589
590        assets - liabilities
591    }
592}
593
594/// A line in a translated trial balance.
595#[derive(Debug, Clone)]
596pub struct TranslatedTrialBalanceLine {
597    /// Account code.
598    pub account_code: String,
599    /// Account description.
600    pub account_description: Option<String>,
601    /// Account type for translation.
602    pub account_type: TranslationAccountType,
603    /// Debit balance in local currency.
604    pub local_debit: Decimal,
605    /// Credit balance in local currency.
606    pub local_credit: Decimal,
607    /// Exchange rate used.
608    pub rate_used: Decimal,
609    /// Rate type used.
610    pub rate_type: RateType,
611    /// Debit balance in group currency.
612    pub group_debit: Decimal,
613    /// Credit balance in group currency.
614    pub group_credit: Decimal,
615}
616
617impl TranslatedTrialBalanceLine {
618    /// Gets the net balance in local currency.
619    pub fn local_net(&self) -> Decimal {
620        self.local_debit - self.local_credit
621    }
622
623    /// Gets the net balance in group currency.
624    pub fn group_net(&self) -> Decimal {
625        self.group_debit - self.group_credit
626    }
627}
628
629#[cfg(test)]
630#[allow(clippy::unwrap_used)]
631mod tests {
632    use super::*;
633    use datasynth_core::models::balance::{
634        AccountCategory, AccountType, TrialBalanceLine, TrialBalanceType,
635    };
636    use datasynth_core::models::FxRate;
637
638    fn create_test_trial_balance() -> TrialBalance {
639        let mut tb = TrialBalance::new(
640            "TB-TEST-2024-12".to_string(),
641            "1200".to_string(),
642            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
643            2024,
644            12,
645            "EUR".to_string(),
646            TrialBalanceType::PostClosing,
647        );
648        tb.company_name = Some("Test Subsidiary".to_string());
649
650        tb.add_line(TrialBalanceLine {
651            account_code: "1000".to_string(),
652            account_description: "Cash".to_string(),
653            category: AccountCategory::CurrentAssets,
654            account_type: AccountType::Asset,
655            opening_balance: Decimal::ZERO,
656            period_debits: dec!(100000),
657            period_credits: Decimal::ZERO,
658            closing_balance: dec!(100000),
659            debit_balance: dec!(100000),
660            credit_balance: Decimal::ZERO,
661            cost_center: None,
662            profit_center: None,
663        });
664
665        tb.add_line(TrialBalanceLine {
666            account_code: "2000".to_string(),
667            account_description: "Accounts Payable".to_string(),
668            category: AccountCategory::CurrentLiabilities,
669            account_type: AccountType::Liability,
670            opening_balance: Decimal::ZERO,
671            period_debits: Decimal::ZERO,
672            period_credits: dec!(50000),
673            closing_balance: dec!(50000),
674            debit_balance: Decimal::ZERO,
675            credit_balance: dec!(50000),
676            cost_center: None,
677            profit_center: None,
678        });
679
680        tb.add_line(TrialBalanceLine {
681            account_code: "4000".to_string(),
682            account_description: "Revenue".to_string(),
683            category: AccountCategory::Revenue,
684            account_type: AccountType::Revenue,
685            opening_balance: Decimal::ZERO,
686            period_debits: Decimal::ZERO,
687            period_credits: dec!(150000),
688            closing_balance: dec!(150000),
689            debit_balance: Decimal::ZERO,
690            credit_balance: dec!(150000),
691            cost_center: None,
692            profit_center: None,
693        });
694
695        tb.add_line(TrialBalanceLine {
696            account_code: "5000".to_string(),
697            account_description: "Expenses".to_string(),
698            category: AccountCategory::OperatingExpenses,
699            account_type: AccountType::Expense,
700            opening_balance: Decimal::ZERO,
701            period_debits: dec!(100000),
702            period_credits: Decimal::ZERO,
703            closing_balance: dec!(100000),
704            debit_balance: dec!(100000),
705            credit_balance: Decimal::ZERO,
706            cost_center: None,
707            profit_center: None,
708        });
709
710        tb
711    }
712
713    #[test]
714    fn test_translate_trial_balance() {
715        let translator = CurrencyTranslator::new(CurrencyTranslatorConfig::default());
716        let trial_balance = create_test_trial_balance();
717
718        let mut rate_table = FxRateTable::new("USD");
719        rate_table.add_rate(FxRate::new(
720            "EUR",
721            "USD",
722            RateType::Closing,
723            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
724            dec!(1.10),
725            "TEST",
726        ));
727        rate_table.add_rate(FxRate::new(
728            "EUR",
729            "USD",
730            RateType::Average,
731            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
732            dec!(1.08),
733            "TEST",
734        ));
735
736        let historical_rates = HashMap::new();
737        let translated =
738            translator.translate_trial_balance(&trial_balance, &rate_table, &historical_rates);
739
740        assert!(translated.is_local_balanced());
741        assert_eq!(translated.closing_rate, dec!(1.10));
742        assert_eq!(translated.average_rate, dec!(1.08));
743    }
744
745    #[test]
746    fn test_is_monetary() {
747        // Cash and cash equivalents - monetary
748        assert!(is_monetary("1000"));
749        assert!(is_monetary("1001"));
750        assert!(is_monetary("1099"));
751
752        // Accounts receivable - monetary
753        assert!(is_monetary("1100"));
754        assert!(is_monetary("1150"));
755
756        // Short-term investments - monetary
757        assert!(is_monetary("1200"));
758
759        // Notes receivable - monetary
760        assert!(is_monetary("1300"));
761
762        // Prepaid expenses - non-monetary
763        assert!(!is_monetary("1400"));
764        assert!(!is_monetary("1450"));
765
766        // Inventory - non-monetary
767        assert!(!is_monetary("1500"));
768        assert!(!is_monetary("1550"));
769
770        // PP&E - non-monetary
771        assert!(!is_monetary("1600"));
772        assert!(!is_monetary("1650"));
773
774        // Intangible assets - non-monetary
775        assert!(!is_monetary("1700"));
776
777        // Long-term investments - non-monetary
778        assert!(!is_monetary("1800"));
779
780        // Other non-current assets - non-monetary
781        assert!(!is_monetary("1900"));
782
783        // Liabilities - all monetary
784        assert!(is_monetary("2000"));
785        assert!(is_monetary("2100"));
786        assert!(is_monetary("2500"));
787        assert!(is_monetary("2900"));
788
789        // Equity - non-monetary
790        assert!(!is_monetary("3000"));
791        assert!(!is_monetary("3100"));
792        assert!(!is_monetary("3200"));
793        assert!(!is_monetary("3900"));
794
795        // Revenue - treated as monetary (average rate in temporal)
796        assert!(is_monetary("4000"));
797        assert!(is_monetary("4500"));
798
799        // Expenses - treated as monetary (average rate in temporal)
800        assert!(is_monetary("5000"));
801        assert!(is_monetary("6000"));
802
803        // Short codes default to monetary
804        assert!(is_monetary("1"));
805        assert!(is_monetary(""));
806    }
807
808    #[test]
809    fn test_historical_equity_rates() {
810        let mut translator = CurrencyTranslator::new(CurrencyTranslatorConfig::default());
811
812        // Initially empty
813        assert!(translator.historical_equity_rates.is_empty());
814
815        // Set historical equity rates
816        let mut rates = HashMap::new();
817        rates.insert("3100".to_string(), dec!(1.05));
818        rates.insert("3200".to_string(), dec!(0.98));
819        translator.set_historical_equity_rates(rates);
820
821        assert_eq!(translator.historical_equity_rates.len(), 2);
822        assert_eq!(
823            translator.historical_equity_rates.get("3100"),
824            Some(&dec!(1.05))
825        );
826        assert_eq!(
827            translator.historical_equity_rates.get("3200"),
828            Some(&dec!(0.98))
829        );
830
831        // Verify these rates are used in determine_rate for CurrentRate method
832        let rate = translator.determine_rate(
833            "3100",
834            &TranslationAccountType::CommonStock,
835            dec!(1.10),
836            dec!(1.08),
837            &HashMap::new(),
838        );
839        assert_eq!(rate, dec!(1.05));
840    }
841
842    #[test]
843    fn test_temporal_method_monetary_vs_non_monetary() {
844        let config = CurrencyTranslatorConfig {
845            method: TranslationMethod::Temporal,
846            ..CurrencyTranslatorConfig::default()
847        };
848        let translator = CurrencyTranslator::new(config);
849
850        let closing_rate = dec!(1.10);
851        let average_rate = dec!(1.08);
852        let mut historical_rates = HashMap::new();
853        historical_rates.insert("1500".to_string(), dec!(1.02)); // historical rate for inventory
854
855        // Cash (1000) is monetary -> closing rate
856        let rate = translator.determine_rate(
857            "1000",
858            &TranslationAccountType::Asset,
859            closing_rate,
860            average_rate,
861            &historical_rates,
862        );
863        assert_eq!(rate, closing_rate);
864
865        // Accounts receivable (1100) is monetary -> closing rate
866        let rate = translator.determine_rate(
867            "1100",
868            &TranslationAccountType::Asset,
869            closing_rate,
870            average_rate,
871            &historical_rates,
872        );
873        assert_eq!(rate, closing_rate);
874
875        // Inventory (1500) is non-monetary -> historical rate
876        let rate = translator.determine_rate(
877            "1500",
878            &TranslationAccountType::Asset,
879            closing_rate,
880            average_rate,
881            &historical_rates,
882        );
883        assert_eq!(rate, dec!(1.02));
884
885        // PP&E (1600) is non-monetary -> falls back to closing rate (no historical rate set)
886        let rate = translator.determine_rate(
887            "1600",
888            &TranslationAccountType::Asset,
889            closing_rate,
890            average_rate,
891            &historical_rates,
892        );
893        assert_eq!(rate, closing_rate);
894
895        // Liabilities (2000) are monetary -> closing rate
896        let rate = translator.determine_rate(
897            "2000",
898            &TranslationAccountType::Liability,
899            closing_rate,
900            average_rate,
901            &historical_rates,
902        );
903        assert_eq!(rate, closing_rate);
904
905        // Revenue (4000) -> average rate under temporal method
906        let rate = translator.determine_rate(
907            "4000",
908            &TranslationAccountType::Revenue,
909            closing_rate,
910            average_rate,
911            &historical_rates,
912        );
913        assert_eq!(rate, average_rate);
914
915        // Expenses (5000) -> average rate under temporal method
916        let rate = translator.determine_rate(
917            "5000",
918            &TranslationAccountType::Expense,
919            closing_rate,
920            average_rate,
921            &historical_rates,
922        );
923        assert_eq!(rate, average_rate);
924
925        // Equity accounts -> historical equity rate (via lookup)
926        let mut translator_with_equity = CurrencyTranslator::new(CurrencyTranslatorConfig {
927            method: TranslationMethod::Temporal,
928            ..CurrencyTranslatorConfig::default()
929        });
930        let mut equity_rates = HashMap::new();
931        equity_rates.insert("3100".to_string(), dec!(0.95));
932        translator_with_equity.set_historical_equity_rates(equity_rates);
933
934        let rate = translator_with_equity.determine_rate(
935            "3100",
936            &TranslationAccountType::CommonStock,
937            closing_rate,
938            average_rate,
939            &historical_rates,
940        );
941        assert_eq!(rate, dec!(0.95));
942    }
943
944    #[test]
945    fn test_monetary_non_monetary_method() {
946        let config = CurrencyTranslatorConfig {
947            method: TranslationMethod::MonetaryNonMonetary,
948            ..CurrencyTranslatorConfig::default()
949        };
950        let mut translator = CurrencyTranslator::new(config);
951
952        let closing_rate = dec!(1.10);
953        let average_rate = dec!(1.08);
954        let mut historical_rates = HashMap::new();
955        historical_rates.insert("1500".to_string(), dec!(1.02));
956        historical_rates.insert("1600".to_string(), dec!(0.99));
957
958        // Set historical equity rates
959        let mut equity_rates = HashMap::new();
960        equity_rates.insert("3100".to_string(), dec!(1.05));
961        equity_rates.insert("3300".to_string(), dec!(1.03));
962        translator.set_historical_equity_rates(equity_rates);
963
964        // Cash (1000) is monetary -> closing rate
965        let rate = translator.determine_rate(
966            "1000",
967            &TranslationAccountType::Asset,
968            closing_rate,
969            average_rate,
970            &historical_rates,
971        );
972        assert_eq!(rate, closing_rate);
973
974        // Accounts receivable (1100) is monetary -> closing rate
975        let rate = translator.determine_rate(
976            "1100",
977            &TranslationAccountType::Asset,
978            closing_rate,
979            average_rate,
980            &historical_rates,
981        );
982        assert_eq!(rate, closing_rate);
983
984        // Inventory (1500) is non-monetary -> historical rate
985        let rate = translator.determine_rate(
986            "1500",
987            &TranslationAccountType::Asset,
988            closing_rate,
989            average_rate,
990            &historical_rates,
991        );
992        assert_eq!(rate, dec!(1.02));
993
994        // PP&E (1600) is non-monetary -> historical rate
995        let rate = translator.determine_rate(
996            "1600",
997            &TranslationAccountType::Asset,
998            closing_rate,
999            average_rate,
1000            &historical_rates,
1001        );
1002        assert_eq!(rate, dec!(0.99));
1003
1004        // Liabilities (2000) are monetary -> closing rate
1005        let rate = translator.determine_rate(
1006            "2000",
1007            &TranslationAccountType::Liability,
1008            closing_rate,
1009            average_rate,
1010            &historical_rates,
1011        );
1012        assert_eq!(rate, closing_rate);
1013
1014        // Equity: Common Stock (3100) -> uses historical equity rate from instance
1015        let rate = translator.determine_rate(
1016            "3100",
1017            &TranslationAccountType::CommonStock,
1018            closing_rate,
1019            average_rate,
1020            &historical_rates,
1021        );
1022        assert_eq!(rate, dec!(1.05));
1023
1024        // Equity: Retained Earnings (3300) -> uses historical equity rate from instance
1025        let rate = translator.determine_rate(
1026            "3300",
1027            &TranslationAccountType::RetainedEarnings,
1028            closing_rate,
1029            average_rate,
1030            &historical_rates,
1031        );
1032        assert_eq!(rate, dec!(1.03));
1033
1034        // Revenue (4000) is monetary -> closing rate (not average like temporal)
1035        let rate = translator.determine_rate(
1036            "4000",
1037            &TranslationAccountType::Revenue,
1038            closing_rate,
1039            average_rate,
1040            &historical_rates,
1041        );
1042        assert_eq!(rate, closing_rate);
1043
1044        // Expenses (5000) is monetary -> closing rate
1045        let rate = translator.determine_rate(
1046            "5000",
1047            &TranslationAccountType::Expense,
1048            closing_rate,
1049            average_rate,
1050            &historical_rates,
1051        );
1052        assert_eq!(rate, closing_rate);
1053    }
1054}