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