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)]
630mod tests {
631    use super::*;
632    use datasynth_core::models::balance::{
633        AccountCategory, AccountType, TrialBalanceLine, TrialBalanceType,
634    };
635    use datasynth_core::models::FxRate;
636
637    fn create_test_trial_balance() -> TrialBalance {
638        let mut tb = TrialBalance::new(
639            "TB-TEST-2024-12".to_string(),
640            "1200".to_string(),
641            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
642            2024,
643            12,
644            "EUR".to_string(),
645            TrialBalanceType::PostClosing,
646        );
647        tb.company_name = Some("Test Subsidiary".to_string());
648
649        tb.add_line(TrialBalanceLine {
650            account_code: "1000".to_string(),
651            account_description: "Cash".to_string(),
652            category: AccountCategory::CurrentAssets,
653            account_type: AccountType::Asset,
654            opening_balance: Decimal::ZERO,
655            period_debits: dec!(100000),
656            period_credits: Decimal::ZERO,
657            closing_balance: dec!(100000),
658            debit_balance: dec!(100000),
659            credit_balance: Decimal::ZERO,
660            cost_center: None,
661            profit_center: None,
662        });
663
664        tb.add_line(TrialBalanceLine {
665            account_code: "2000".to_string(),
666            account_description: "Accounts Payable".to_string(),
667            category: AccountCategory::CurrentLiabilities,
668            account_type: AccountType::Liability,
669            opening_balance: Decimal::ZERO,
670            period_debits: Decimal::ZERO,
671            period_credits: dec!(50000),
672            closing_balance: dec!(50000),
673            debit_balance: Decimal::ZERO,
674            credit_balance: dec!(50000),
675            cost_center: None,
676            profit_center: None,
677        });
678
679        tb.add_line(TrialBalanceLine {
680            account_code: "4000".to_string(),
681            account_description: "Revenue".to_string(),
682            category: AccountCategory::Revenue,
683            account_type: AccountType::Revenue,
684            opening_balance: Decimal::ZERO,
685            period_debits: Decimal::ZERO,
686            period_credits: dec!(150000),
687            closing_balance: dec!(150000),
688            debit_balance: Decimal::ZERO,
689            credit_balance: dec!(150000),
690            cost_center: None,
691            profit_center: None,
692        });
693
694        tb.add_line(TrialBalanceLine {
695            account_code: "5000".to_string(),
696            account_description: "Expenses".to_string(),
697            category: AccountCategory::OperatingExpenses,
698            account_type: AccountType::Expense,
699            opening_balance: Decimal::ZERO,
700            period_debits: dec!(100000),
701            period_credits: Decimal::ZERO,
702            closing_balance: dec!(100000),
703            debit_balance: dec!(100000),
704            credit_balance: Decimal::ZERO,
705            cost_center: None,
706            profit_center: None,
707        });
708
709        tb
710    }
711
712    #[test]
713    fn test_translate_trial_balance() {
714        let translator = CurrencyTranslator::new(CurrencyTranslatorConfig::default());
715        let trial_balance = create_test_trial_balance();
716
717        let mut rate_table = FxRateTable::new("USD");
718        rate_table.add_rate(FxRate::new(
719            "EUR",
720            "USD",
721            RateType::Closing,
722            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
723            dec!(1.10),
724            "TEST",
725        ));
726        rate_table.add_rate(FxRate::new(
727            "EUR",
728            "USD",
729            RateType::Average,
730            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
731            dec!(1.08),
732            "TEST",
733        ));
734
735        let historical_rates = HashMap::new();
736        let translated =
737            translator.translate_trial_balance(&trial_balance, &rate_table, &historical_rates);
738
739        assert!(translated.is_local_balanced());
740        assert_eq!(translated.closing_rate, dec!(1.10));
741        assert_eq!(translated.average_rate, dec!(1.08));
742    }
743
744    #[test]
745    fn test_is_monetary() {
746        // Cash and cash equivalents - monetary
747        assert!(is_monetary("1000"));
748        assert!(is_monetary("1001"));
749        assert!(is_monetary("1099"));
750
751        // Accounts receivable - monetary
752        assert!(is_monetary("1100"));
753        assert!(is_monetary("1150"));
754
755        // Short-term investments - monetary
756        assert!(is_monetary("1200"));
757
758        // Notes receivable - monetary
759        assert!(is_monetary("1300"));
760
761        // Prepaid expenses - non-monetary
762        assert!(!is_monetary("1400"));
763        assert!(!is_monetary("1450"));
764
765        // Inventory - non-monetary
766        assert!(!is_monetary("1500"));
767        assert!(!is_monetary("1550"));
768
769        // PP&E - non-monetary
770        assert!(!is_monetary("1600"));
771        assert!(!is_monetary("1650"));
772
773        // Intangible assets - non-monetary
774        assert!(!is_monetary("1700"));
775
776        // Long-term investments - non-monetary
777        assert!(!is_monetary("1800"));
778
779        // Other non-current assets - non-monetary
780        assert!(!is_monetary("1900"));
781
782        // Liabilities - all monetary
783        assert!(is_monetary("2000"));
784        assert!(is_monetary("2100"));
785        assert!(is_monetary("2500"));
786        assert!(is_monetary("2900"));
787
788        // Equity - non-monetary
789        assert!(!is_monetary("3000"));
790        assert!(!is_monetary("3100"));
791        assert!(!is_monetary("3200"));
792        assert!(!is_monetary("3900"));
793
794        // Revenue - treated as monetary (average rate in temporal)
795        assert!(is_monetary("4000"));
796        assert!(is_monetary("4500"));
797
798        // Expenses - treated as monetary (average rate in temporal)
799        assert!(is_monetary("5000"));
800        assert!(is_monetary("6000"));
801
802        // Short codes default to monetary
803        assert!(is_monetary("1"));
804        assert!(is_monetary(""));
805    }
806
807    #[test]
808    fn test_historical_equity_rates() {
809        let mut translator = CurrencyTranslator::new(CurrencyTranslatorConfig::default());
810
811        // Initially empty
812        assert!(translator.historical_equity_rates.is_empty());
813
814        // Set historical equity rates
815        let mut rates = HashMap::new();
816        rates.insert("3100".to_string(), dec!(1.05));
817        rates.insert("3200".to_string(), dec!(0.98));
818        translator.set_historical_equity_rates(rates);
819
820        assert_eq!(translator.historical_equity_rates.len(), 2);
821        assert_eq!(
822            translator.historical_equity_rates.get("3100"),
823            Some(&dec!(1.05))
824        );
825        assert_eq!(
826            translator.historical_equity_rates.get("3200"),
827            Some(&dec!(0.98))
828        );
829
830        // Verify these rates are used in determine_rate for CurrentRate method
831        let rate = translator.determine_rate(
832            "3100",
833            &TranslationAccountType::CommonStock,
834            dec!(1.10),
835            dec!(1.08),
836            &HashMap::new(),
837        );
838        assert_eq!(rate, dec!(1.05));
839    }
840
841    #[test]
842    fn test_temporal_method_monetary_vs_non_monetary() {
843        let config = CurrencyTranslatorConfig {
844            method: TranslationMethod::Temporal,
845            ..CurrencyTranslatorConfig::default()
846        };
847        let translator = CurrencyTranslator::new(config);
848
849        let closing_rate = dec!(1.10);
850        let average_rate = dec!(1.08);
851        let mut historical_rates = HashMap::new();
852        historical_rates.insert("1500".to_string(), dec!(1.02)); // historical rate for inventory
853
854        // Cash (1000) is monetary -> closing rate
855        let rate = translator.determine_rate(
856            "1000",
857            &TranslationAccountType::Asset,
858            closing_rate,
859            average_rate,
860            &historical_rates,
861        );
862        assert_eq!(rate, closing_rate);
863
864        // Accounts receivable (1100) is monetary -> closing rate
865        let rate = translator.determine_rate(
866            "1100",
867            &TranslationAccountType::Asset,
868            closing_rate,
869            average_rate,
870            &historical_rates,
871        );
872        assert_eq!(rate, closing_rate);
873
874        // Inventory (1500) is non-monetary -> historical rate
875        let rate = translator.determine_rate(
876            "1500",
877            &TranslationAccountType::Asset,
878            closing_rate,
879            average_rate,
880            &historical_rates,
881        );
882        assert_eq!(rate, dec!(1.02));
883
884        // PP&E (1600) is non-monetary -> falls back to closing rate (no historical rate set)
885        let rate = translator.determine_rate(
886            "1600",
887            &TranslationAccountType::Asset,
888            closing_rate,
889            average_rate,
890            &historical_rates,
891        );
892        assert_eq!(rate, closing_rate);
893
894        // Liabilities (2000) are monetary -> closing rate
895        let rate = translator.determine_rate(
896            "2000",
897            &TranslationAccountType::Liability,
898            closing_rate,
899            average_rate,
900            &historical_rates,
901        );
902        assert_eq!(rate, closing_rate);
903
904        // Revenue (4000) -> average rate under temporal method
905        let rate = translator.determine_rate(
906            "4000",
907            &TranslationAccountType::Revenue,
908            closing_rate,
909            average_rate,
910            &historical_rates,
911        );
912        assert_eq!(rate, average_rate);
913
914        // Expenses (5000) -> average rate under temporal method
915        let rate = translator.determine_rate(
916            "5000",
917            &TranslationAccountType::Expense,
918            closing_rate,
919            average_rate,
920            &historical_rates,
921        );
922        assert_eq!(rate, average_rate);
923
924        // Equity accounts -> historical equity rate (via lookup)
925        let mut translator_with_equity = CurrencyTranslator::new(CurrencyTranslatorConfig {
926            method: TranslationMethod::Temporal,
927            ..CurrencyTranslatorConfig::default()
928        });
929        let mut equity_rates = HashMap::new();
930        equity_rates.insert("3100".to_string(), dec!(0.95));
931        translator_with_equity.set_historical_equity_rates(equity_rates);
932
933        let rate = translator_with_equity.determine_rate(
934            "3100",
935            &TranslationAccountType::CommonStock,
936            closing_rate,
937            average_rate,
938            &historical_rates,
939        );
940        assert_eq!(rate, dec!(0.95));
941    }
942
943    #[test]
944    fn test_monetary_non_monetary_method() {
945        let config = CurrencyTranslatorConfig {
946            method: TranslationMethod::MonetaryNonMonetary,
947            ..CurrencyTranslatorConfig::default()
948        };
949        let mut translator = CurrencyTranslator::new(config);
950
951        let closing_rate = dec!(1.10);
952        let average_rate = dec!(1.08);
953        let mut historical_rates = HashMap::new();
954        historical_rates.insert("1500".to_string(), dec!(1.02));
955        historical_rates.insert("1600".to_string(), dec!(0.99));
956
957        // Set historical equity rates
958        let mut equity_rates = HashMap::new();
959        equity_rates.insert("3100".to_string(), dec!(1.05));
960        equity_rates.insert("3300".to_string(), dec!(1.03));
961        translator.set_historical_equity_rates(equity_rates);
962
963        // Cash (1000) is monetary -> closing rate
964        let rate = translator.determine_rate(
965            "1000",
966            &TranslationAccountType::Asset,
967            closing_rate,
968            average_rate,
969            &historical_rates,
970        );
971        assert_eq!(rate, closing_rate);
972
973        // Accounts receivable (1100) is monetary -> closing rate
974        let rate = translator.determine_rate(
975            "1100",
976            &TranslationAccountType::Asset,
977            closing_rate,
978            average_rate,
979            &historical_rates,
980        );
981        assert_eq!(rate, closing_rate);
982
983        // Inventory (1500) is non-monetary -> historical rate
984        let rate = translator.determine_rate(
985            "1500",
986            &TranslationAccountType::Asset,
987            closing_rate,
988            average_rate,
989            &historical_rates,
990        );
991        assert_eq!(rate, dec!(1.02));
992
993        // PP&E (1600) is non-monetary -> historical rate
994        let rate = translator.determine_rate(
995            "1600",
996            &TranslationAccountType::Asset,
997            closing_rate,
998            average_rate,
999            &historical_rates,
1000        );
1001        assert_eq!(rate, dec!(0.99));
1002
1003        // Liabilities (2000) are monetary -> closing rate
1004        let rate = translator.determine_rate(
1005            "2000",
1006            &TranslationAccountType::Liability,
1007            closing_rate,
1008            average_rate,
1009            &historical_rates,
1010        );
1011        assert_eq!(rate, closing_rate);
1012
1013        // Equity: Common Stock (3100) -> uses historical equity rate from instance
1014        let rate = translator.determine_rate(
1015            "3100",
1016            &TranslationAccountType::CommonStock,
1017            closing_rate,
1018            average_rate,
1019            &historical_rates,
1020        );
1021        assert_eq!(rate, dec!(1.05));
1022
1023        // Equity: Retained Earnings (3300) -> uses historical equity rate from instance
1024        let rate = translator.determine_rate(
1025            "3300",
1026            &TranslationAccountType::RetainedEarnings,
1027            closing_rate,
1028            average_rate,
1029            &historical_rates,
1030        );
1031        assert_eq!(rate, dec!(1.03));
1032
1033        // Revenue (4000) is monetary -> closing rate (not average like temporal)
1034        let rate = translator.determine_rate(
1035            "4000",
1036            &TranslationAccountType::Revenue,
1037            closing_rate,
1038            average_rate,
1039            &historical_rates,
1040        );
1041        assert_eq!(rate, closing_rate);
1042
1043        // Expenses (5000) is monetary -> closing rate
1044        let rate = translator.determine_rate(
1045            "5000",
1046            &TranslationAccountType::Expense,
1047            closing_rate,
1048            average_rate,
1049            &historical_rates,
1050        );
1051        assert_eq!(rate, closing_rate);
1052    }
1053}