1use 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#[derive(Debug, Clone)]
18pub struct CurrencyTranslatorConfig {
19 pub method: TranslationMethod,
21 pub group_currency: String,
23 pub account_type_map: HashMap<String, TranslationAccountType>,
25 pub historical_rate_accounts: Vec<String>,
27 pub retained_earnings_account: String,
29 pub cta_account: String,
31}
32
33impl Default for CurrencyTranslatorConfig {
34 fn default() -> Self {
35 let mut account_type_map = HashMap::new();
36 account_type_map.insert("1".to_string(), TranslationAccountType::Asset);
38 account_type_map.insert("2".to_string(), TranslationAccountType::Liability);
40 account_type_map.insert("3".to_string(), TranslationAccountType::Equity);
42 account_type_map.insert("4".to_string(), TranslationAccountType::Revenue);
44 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(), "3200".to_string(), ],
56 retained_earnings_account: "3300".to_string(),
57 cta_account: "3900".to_string(),
58 }
59 }
60}
61
62pub struct CurrencyTranslator {
64 config: CurrencyTranslatorConfig,
65}
66
67impl CurrencyTranslator {
68 pub fn new(config: CurrencyTranslatorConfig) -> Self {
70 Self { config }
71 }
72
73 pub fn translate_trial_balance(
75 &self,
76 trial_balance: &TrialBalance,
77 rate_table: &FxRateTable,
78 historical_rates: &HashMap<String, Decimal>,
79 ) -> TranslatedTrialBalance {
80 let local_currency = &trial_balance.currency;
81 let period_end = trial_balance.as_of_date;
82
83 let closing_rate = rate_table
85 .get_closing_rate(local_currency, &self.config.group_currency, period_end)
86 .map(|r| r.rate)
87 .unwrap_or(Decimal::ONE);
88
89 let average_rate = rate_table
90 .get_average_rate(local_currency, &self.config.group_currency, period_end)
91 .map(|r| r.rate)
92 .unwrap_or(closing_rate);
93
94 let mut translated_lines = Vec::new();
95 let mut total_local_debit = Decimal::ZERO;
96 let mut total_local_credit = Decimal::ZERO;
97 let mut total_group_debit = Decimal::ZERO;
98 let mut total_group_credit = Decimal::ZERO;
99
100 for line in &trial_balance.lines {
101 let account_type = self.determine_account_type(&line.account_code);
102 let rate = self.determine_rate(
103 &line.account_code,
104 &account_type,
105 closing_rate,
106 average_rate,
107 historical_rates,
108 );
109
110 let group_debit = (line.debit_balance * rate).round_dp(2);
111 let group_credit = (line.credit_balance * rate).round_dp(2);
112
113 translated_lines.push(TranslatedTrialBalanceLine {
114 account_code: line.account_code.clone(),
115 account_description: Some(line.account_description.clone()),
116 account_type: account_type.clone(),
117 local_debit: line.debit_balance,
118 local_credit: line.credit_balance,
119 rate_used: rate,
120 rate_type: self.rate_type_for_account(&account_type),
121 group_debit,
122 group_credit,
123 });
124
125 total_local_debit += line.debit_balance;
126 total_local_credit += line.credit_balance;
127 total_group_debit += group_debit;
128 total_group_credit += group_credit;
129 }
130
131 let cta_amount = total_group_debit - total_group_credit;
133
134 TranslatedTrialBalance {
135 company_code: trial_balance.company_code.clone(),
136 company_name: trial_balance.company_name.clone().unwrap_or_default(),
137 local_currency: local_currency.clone(),
138 group_currency: self.config.group_currency.clone(),
139 period_end_date: period_end,
140 fiscal_year: trial_balance.fiscal_year,
141 fiscal_period: trial_balance.fiscal_period as u8,
142 lines: translated_lines,
143 closing_rate,
144 average_rate,
145 total_local_debit,
146 total_local_credit,
147 total_group_debit,
148 total_group_credit,
149 cta_amount,
150 translation_method: self.config.method.clone(),
151 }
152 }
153
154 pub fn translate_amount(
156 &self,
157 amount: Decimal,
158 local_currency: &str,
159 account_type: &TranslationAccountType,
160 rate_table: &FxRateTable,
161 date: NaiveDate,
162 ) -> TranslatedAmount {
163 let (rate, rate_type) = match account_type {
164 TranslationAccountType::Asset | TranslationAccountType::Liability => {
165 let rate = rate_table
166 .get_closing_rate(local_currency, &self.config.group_currency, date)
167 .map(|r| r.rate)
168 .unwrap_or(Decimal::ONE);
169 (rate, RateType::Closing)
170 }
171 TranslationAccountType::Revenue | TranslationAccountType::Expense => {
172 let rate = rate_table
173 .get_average_rate(local_currency, &self.config.group_currency, date)
174 .map(|r| r.rate)
175 .unwrap_or(Decimal::ONE);
176 (rate, RateType::Average)
177 }
178 TranslationAccountType::Equity
179 | TranslationAccountType::CommonStock
180 | TranslationAccountType::AdditionalPaidInCapital => {
181 (Decimal::ONE, RateType::Historical) }
183 TranslationAccountType::RetainedEarnings => {
184 (Decimal::ONE, RateType::Historical)
186 }
187 };
188
189 TranslatedAmount {
190 local_amount: amount,
191 local_currency: local_currency.to_string(),
192 group_amount: (amount * rate).round_dp(2),
193 group_currency: self.config.group_currency.clone(),
194 rate_used: rate,
195 rate_type,
196 translation_date: date,
197 }
198 }
199
200 fn determine_account_type(&self, account_code: &str) -> TranslationAccountType {
202 if self
204 .config
205 .historical_rate_accounts
206 .contains(&account_code.to_string())
207 {
208 if account_code.starts_with("31") {
209 return TranslationAccountType::CommonStock;
210 } else if account_code.starts_with("32") {
211 return TranslationAccountType::AdditionalPaidInCapital;
212 }
213 }
214
215 if account_code == self.config.retained_earnings_account {
216 return TranslationAccountType::RetainedEarnings;
217 }
218
219 for (prefix, account_type) in &self.config.account_type_map {
221 if account_code.starts_with(prefix) {
222 return account_type.clone();
223 }
224 }
225
226 TranslationAccountType::Asset
228 }
229
230 fn determine_rate(
232 &self,
233 account_code: &str,
234 account_type: &TranslationAccountType,
235 closing_rate: Decimal,
236 average_rate: Decimal,
237 historical_rates: &HashMap<String, Decimal>,
238 ) -> Decimal {
239 match self.config.method {
240 TranslationMethod::CurrentRate => {
241 match account_type {
242 TranslationAccountType::Asset | TranslationAccountType::Liability => {
243 closing_rate
244 }
245 TranslationAccountType::Revenue | TranslationAccountType::Expense => {
246 average_rate
247 }
248 TranslationAccountType::CommonStock
249 | TranslationAccountType::AdditionalPaidInCapital => {
250 historical_rates
252 .get(account_code)
253 .copied()
254 .unwrap_or(closing_rate)
255 }
256 TranslationAccountType::Equity | TranslationAccountType::RetainedEarnings => {
257 closing_rate
259 }
260 }
261 }
262 TranslationMethod::Temporal => {
263 match account_type {
265 TranslationAccountType::Asset => {
266 closing_rate
269 }
270 TranslationAccountType::Liability => closing_rate,
271 _ => average_rate,
272 }
273 }
274 TranslationMethod::MonetaryNonMonetary => closing_rate, }
276 }
277
278 fn rate_type_for_account(&self, account_type: &TranslationAccountType) -> RateType {
280 match account_type {
281 TranslationAccountType::Asset | TranslationAccountType::Liability => RateType::Closing,
282 TranslationAccountType::Revenue | TranslationAccountType::Expense => RateType::Average,
283 TranslationAccountType::Equity
284 | TranslationAccountType::CommonStock
285 | TranslationAccountType::AdditionalPaidInCapital
286 | TranslationAccountType::RetainedEarnings => RateType::Historical,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
293pub struct TranslatedTrialBalance {
294 pub company_code: String,
296 pub company_name: String,
298 pub local_currency: String,
300 pub group_currency: String,
302 pub period_end_date: NaiveDate,
304 pub fiscal_year: i32,
306 pub fiscal_period: u8,
308 pub lines: Vec<TranslatedTrialBalanceLine>,
310 pub closing_rate: Decimal,
312 pub average_rate: Decimal,
314 pub total_local_debit: Decimal,
316 pub total_local_credit: Decimal,
318 pub total_group_debit: Decimal,
320 pub total_group_credit: Decimal,
322 pub cta_amount: Decimal,
324 pub translation_method: TranslationMethod,
326}
327
328impl TranslatedTrialBalance {
329 pub fn is_local_balanced(&self) -> bool {
331 (self.total_local_debit - self.total_local_credit).abs() < dec!(0.01)
332 }
333
334 pub fn is_group_balanced(&self) -> bool {
336 let balance = self.total_group_debit - self.total_group_credit - self.cta_amount;
337 balance.abs() < dec!(0.01)
338 }
339
340 pub fn local_net_assets(&self) -> Decimal {
342 let assets: Decimal = self
343 .lines
344 .iter()
345 .filter(|l| matches!(l.account_type, TranslationAccountType::Asset))
346 .map(|l| l.local_debit - l.local_credit)
347 .sum();
348
349 let liabilities: Decimal = self
350 .lines
351 .iter()
352 .filter(|l| matches!(l.account_type, TranslationAccountType::Liability))
353 .map(|l| l.local_credit - l.local_debit)
354 .sum();
355
356 assets - liabilities
357 }
358
359 pub fn group_net_assets(&self) -> Decimal {
361 let assets: Decimal = self
362 .lines
363 .iter()
364 .filter(|l| matches!(l.account_type, TranslationAccountType::Asset))
365 .map(|l| l.group_debit - l.group_credit)
366 .sum();
367
368 let liabilities: Decimal = self
369 .lines
370 .iter()
371 .filter(|l| matches!(l.account_type, TranslationAccountType::Liability))
372 .map(|l| l.group_credit - l.group_debit)
373 .sum();
374
375 assets - liabilities
376 }
377}
378
379#[derive(Debug, Clone)]
381pub struct TranslatedTrialBalanceLine {
382 pub account_code: String,
384 pub account_description: Option<String>,
386 pub account_type: TranslationAccountType,
388 pub local_debit: Decimal,
390 pub local_credit: Decimal,
392 pub rate_used: Decimal,
394 pub rate_type: RateType,
396 pub group_debit: Decimal,
398 pub group_credit: Decimal,
400}
401
402impl TranslatedTrialBalanceLine {
403 pub fn local_net(&self) -> Decimal {
405 self.local_debit - self.local_credit
406 }
407
408 pub fn group_net(&self) -> Decimal {
410 self.group_debit - self.group_credit
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use datasynth_core::models::balance::{
418 AccountCategory, AccountType, TrialBalanceLine, TrialBalanceType,
419 };
420 use datasynth_core::models::FxRate;
421
422 fn create_test_trial_balance() -> TrialBalance {
423 let mut tb = TrialBalance::new(
424 "TB-TEST-2024-12".to_string(),
425 "1200".to_string(),
426 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
427 2024,
428 12,
429 "EUR".to_string(),
430 TrialBalanceType::PostClosing,
431 );
432 tb.company_name = Some("Test Subsidiary".to_string());
433
434 tb.add_line(TrialBalanceLine {
435 account_code: "1000".to_string(),
436 account_description: "Cash".to_string(),
437 category: AccountCategory::CurrentAssets,
438 account_type: AccountType::Asset,
439 opening_balance: Decimal::ZERO,
440 period_debits: dec!(100000),
441 period_credits: Decimal::ZERO,
442 closing_balance: dec!(100000),
443 debit_balance: dec!(100000),
444 credit_balance: Decimal::ZERO,
445 cost_center: None,
446 profit_center: None,
447 });
448
449 tb.add_line(TrialBalanceLine {
450 account_code: "2000".to_string(),
451 account_description: "Accounts Payable".to_string(),
452 category: AccountCategory::CurrentLiabilities,
453 account_type: AccountType::Liability,
454 opening_balance: Decimal::ZERO,
455 period_debits: Decimal::ZERO,
456 period_credits: dec!(50000),
457 closing_balance: dec!(50000),
458 debit_balance: Decimal::ZERO,
459 credit_balance: dec!(50000),
460 cost_center: None,
461 profit_center: None,
462 });
463
464 tb.add_line(TrialBalanceLine {
465 account_code: "4000".to_string(),
466 account_description: "Revenue".to_string(),
467 category: AccountCategory::Revenue,
468 account_type: AccountType::Revenue,
469 opening_balance: Decimal::ZERO,
470 period_debits: Decimal::ZERO,
471 period_credits: dec!(150000),
472 closing_balance: dec!(150000),
473 debit_balance: Decimal::ZERO,
474 credit_balance: dec!(150000),
475 cost_center: None,
476 profit_center: None,
477 });
478
479 tb.add_line(TrialBalanceLine {
480 account_code: "5000".to_string(),
481 account_description: "Expenses".to_string(),
482 category: AccountCategory::OperatingExpenses,
483 account_type: AccountType::Expense,
484 opening_balance: Decimal::ZERO,
485 period_debits: dec!(100000),
486 period_credits: Decimal::ZERO,
487 closing_balance: dec!(100000),
488 debit_balance: dec!(100000),
489 credit_balance: Decimal::ZERO,
490 cost_center: None,
491 profit_center: None,
492 });
493
494 tb
495 }
496
497 #[test]
498 fn test_translate_trial_balance() {
499 let translator = CurrencyTranslator::new(CurrencyTranslatorConfig::default());
500 let trial_balance = create_test_trial_balance();
501
502 let mut rate_table = FxRateTable::new("USD");
503 rate_table.add_rate(FxRate::new(
504 "EUR",
505 "USD",
506 RateType::Closing,
507 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
508 dec!(1.10),
509 "TEST",
510 ));
511 rate_table.add_rate(FxRate::new(
512 "EUR",
513 "USD",
514 RateType::Average,
515 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
516 dec!(1.08),
517 "TEST",
518 ));
519
520 let historical_rates = HashMap::new();
521 let translated =
522 translator.translate_trial_balance(&trial_balance, &rate_table, &historical_rates);
523
524 assert!(translated.is_local_balanced());
525 assert_eq!(translated.closing_rate, dec!(1.10));
526 assert_eq!(translated.average_rate, dec!(1.08));
527 }
528}