1use chrono::NaiveDate;
6use rust_decimal::Decimal;
7use std::collections::HashMap;
8
9use datasynth_core::models::{CTAEntry, FxRateTable, JournalEntry, JournalEntryLine};
10
11use super::currency_translator::TranslatedTrialBalance;
12
13#[derive(Debug, Clone)]
15pub struct CTAGeneratorConfig {
16 pub group_currency: String,
18 pub cta_account: String,
20 pub accumulated_cta_account: String,
22 pub generate_detailed_breakdown: bool,
24}
25
26impl Default for CTAGeneratorConfig {
27 fn default() -> Self {
28 Self {
29 group_currency: "USD".to_string(),
30 cta_account: "3900".to_string(),
31 accumulated_cta_account: "3910".to_string(),
32 generate_detailed_breakdown: true,
33 }
34 }
35}
36
37pub struct CTAGenerator {
39 config: CTAGeneratorConfig,
40 cta_counter: u64,
41}
42
43impl CTAGenerator {
44 pub fn new(config: CTAGeneratorConfig) -> Self {
46 Self {
47 config,
48 cta_counter: 0,
49 }
50 }
51
52 pub fn generate_cta(
54 &mut self,
55 company_code: &str,
56 local_currency: &str,
57 fiscal_year: i32,
58 fiscal_period: u8,
59 period_end_date: NaiveDate,
60 opening_net_assets_local: Decimal,
61 closing_net_assets_local: Decimal,
62 net_income_local: Decimal,
63 rate_table: &FxRateTable,
64 opening_rate: Option<Decimal>,
65 ) -> (CTAEntry, JournalEntry) {
66 self.cta_counter += 1;
67 let entry_id = format!("CTA-{:08}", self.cta_counter);
68
69 let closing_rate = rate_table
71 .get_closing_rate(local_currency, &self.config.group_currency, period_end_date)
72 .map(|r| r.rate)
73 .unwrap_or(Decimal::ONE);
74
75 let average_rate = rate_table
76 .get_average_rate(local_currency, &self.config.group_currency, period_end_date)
77 .map(|r| r.rate)
78 .unwrap_or(closing_rate);
79
80 let opening_rate = opening_rate.unwrap_or(closing_rate);
81
82 let mut cta = CTAEntry::new(
83 entry_id.clone(),
84 company_code.to_string(),
85 local_currency.to_string(),
86 self.config.group_currency.clone(),
87 fiscal_year,
88 fiscal_period,
89 period_end_date,
90 );
91
92 cta.opening_net_assets_local = opening_net_assets_local;
93 cta.closing_net_assets_local = closing_net_assets_local;
94 cta.net_income_local = net_income_local;
95 cta.opening_rate = opening_rate;
96 cta.closing_rate = closing_rate;
97 cta.average_rate = average_rate;
98
99 cta.calculate_current_rate_method();
101
102 let je = self.generate_cta_journal_entry(&cta);
104
105 (cta, je)
106 }
107
108 pub fn generate_cta_from_translation(
110 &mut self,
111 current_period: &TranslatedTrialBalance,
112 prior_period: Option<&TranslatedTrialBalance>,
113 net_income_local: Decimal,
114 ) -> (CTAEntry, JournalEntry) {
115 self.cta_counter += 1;
116 let entry_id = format!("CTA-{:08}", self.cta_counter);
117
118 let opening_net_assets = prior_period
119 .map(|tb| tb.local_net_assets())
120 .unwrap_or(Decimal::ZERO);
121
122 let opening_rate = prior_period
123 .map(|tb| tb.closing_rate)
124 .unwrap_or(current_period.closing_rate);
125
126 let mut cta = CTAEntry::new(
127 entry_id.clone(),
128 current_period.company_code.clone(),
129 current_period.local_currency.clone(),
130 current_period.group_currency.clone(),
131 current_period.fiscal_year,
132 current_period.fiscal_period,
133 current_period.period_end_date,
134 );
135
136 cta.opening_net_assets_local = opening_net_assets;
137 cta.closing_net_assets_local = current_period.local_net_assets();
138 cta.net_income_local = net_income_local;
139 cta.opening_rate = opening_rate;
140 cta.closing_rate = current_period.closing_rate;
141 cta.average_rate = current_period.average_rate;
142
143 cta.calculate_current_rate_method();
144
145 let je = self.generate_cta_journal_entry(&cta);
146
147 (cta, je)
148 }
149
150 pub fn generate_cta_for_subsidiaries(
152 &mut self,
153 subsidiaries: &[SubsidiaryCTAInput],
154 rate_table: &FxRateTable,
155 ) -> Vec<(CTAEntry, JournalEntry)> {
156 subsidiaries
157 .iter()
158 .map(|sub| {
159 self.generate_cta(
160 &sub.company_code,
161 &sub.local_currency,
162 sub.fiscal_year,
163 sub.fiscal_period,
164 sub.period_end_date,
165 sub.opening_net_assets_local,
166 sub.closing_net_assets_local,
167 sub.net_income_local,
168 rate_table,
169 sub.opening_rate,
170 )
171 })
172 .collect()
173 }
174
175 fn generate_cta_journal_entry(&self, cta: &CTAEntry) -> JournalEntry {
177 let mut je = JournalEntry::new_simple(
178 format!("JE-{}", cta.entry_id),
179 cta.company_code.clone(),
180 cta.period_end_date,
181 format!(
182 "CTA {} Period {}/{}",
183 cta.company_code, cta.fiscal_year, cta.fiscal_period
184 ),
185 );
186
187 if cta.cta_amount >= Decimal::ZERO {
188 je.add_line(JournalEntryLine {
190 line_number: 1,
191 gl_account: self.config.accumulated_cta_account.clone(),
192 debit_amount: cta.cta_amount,
193 reference: Some(cta.entry_id.clone()),
194 text: Some("CTA - Net Assets Translation".to_string()),
195 ..Default::default()
196 });
197
198 je.add_line(JournalEntryLine {
199 line_number: 2,
200 gl_account: self.config.cta_account.clone(),
201 credit_amount: cta.cta_amount,
202 reference: Some(cta.entry_id.clone()),
203 text: Some(format!(
204 "CTA: {} @ {} -> {}",
205 cta.local_currency, cta.closing_rate, cta.group_currency
206 )),
207 ..Default::default()
208 });
209 } else {
210 let abs_amount = cta.cta_amount.abs();
212
213 je.add_line(JournalEntryLine {
214 line_number: 1,
215 gl_account: self.config.cta_account.clone(),
216 debit_amount: abs_amount,
217 reference: Some(cta.entry_id.clone()),
218 text: Some(format!(
219 "CTA: {} @ {} -> {}",
220 cta.local_currency, cta.closing_rate, cta.group_currency
221 )),
222 ..Default::default()
223 });
224
225 je.add_line(JournalEntryLine {
226 line_number: 2,
227 gl_account: self.config.accumulated_cta_account.clone(),
228 credit_amount: abs_amount,
229 reference: Some(cta.entry_id.clone()),
230 text: Some("CTA - Net Assets Translation".to_string()),
231 ..Default::default()
232 });
233 }
234
235 je
236 }
237}
238
239#[derive(Debug, Clone)]
241pub struct SubsidiaryCTAInput {
242 pub company_code: String,
244 pub local_currency: String,
246 pub fiscal_year: i32,
248 pub fiscal_period: u8,
250 pub period_end_date: NaiveDate,
252 pub opening_net_assets_local: Decimal,
254 pub closing_net_assets_local: Decimal,
256 pub net_income_local: Decimal,
258 pub opening_rate: Option<Decimal>,
260}
261
262#[derive(Debug, Clone)]
264pub struct CTASummary {
265 pub fiscal_year: i32,
267 pub fiscal_period: u8,
269 pub period_end_date: NaiveDate,
271 pub group_currency: String,
273 pub entries: Vec<CTAEntry>,
275 pub total_cta: Decimal,
277 pub cta_by_currency: HashMap<String, Decimal>,
279}
280
281impl CTASummary {
282 pub fn from_entries(
284 entries: Vec<CTAEntry>,
285 fiscal_year: i32,
286 fiscal_period: u8,
287 period_end_date: NaiveDate,
288 group_currency: String,
289 ) -> Self {
290 let total_cta: Decimal = entries.iter().map(|e| e.cta_amount).sum();
291
292 let mut cta_by_currency: HashMap<String, Decimal> = HashMap::new();
293 for entry in &entries {
294 *cta_by_currency
295 .entry(entry.local_currency.clone())
296 .or_insert(Decimal::ZERO) += entry.cta_amount;
297 }
298
299 Self {
300 fiscal_year,
301 fiscal_period,
302 period_end_date,
303 group_currency,
304 entries,
305 total_cta,
306 cta_by_currency,
307 }
308 }
309
310 pub fn summary(&self) -> String {
312 let mut summary = format!(
313 "CTA Summary for Period {}/{} ending {}\n",
314 self.fiscal_year, self.fiscal_period, self.period_end_date
315 );
316 summary.push_str(&format!(
317 "Total CTA: {} {}\n",
318 self.total_cta, self.group_currency
319 ));
320 summary.push_str("By Currency:\n");
321 for (currency, amount) in &self.cta_by_currency {
322 summary.push_str(&format!(
323 " {}: {} {}\n",
324 currency, amount, self.group_currency
325 ));
326 }
327 summary
328 }
329}
330
331#[derive(Debug, Clone)]
333pub struct CTAAnalysis {
334 pub entry: CTAEntry,
336 pub balance_sheet_impact: Decimal,
338 pub income_statement_impact: Decimal,
340 pub rate_change_impact: Decimal,
342 pub breakdown: Vec<CTABreakdownItem>,
344}
345
346#[derive(Debug, Clone)]
348pub struct CTABreakdownItem {
349 pub description: String,
351 pub local_amount: Decimal,
353 pub rate: Decimal,
355 pub group_amount: Decimal,
357 pub cta_impact: Decimal,
359}
360
361impl CTAAnalysis {
362 pub fn from_entry(entry: CTAEntry) -> Self {
364 let opening_at_opening = entry.opening_net_assets_local * entry.opening_rate;
366 let opening_at_closing = entry.opening_net_assets_local * entry.closing_rate;
367 let rate_change_impact = opening_at_closing - opening_at_opening;
368
369 let income_at_average = entry.net_income_local * entry.average_rate;
370 let income_at_closing = entry.net_income_local * entry.closing_rate;
371 let income_statement_impact = income_at_closing - income_at_average;
372
373 let balance_sheet_impact = entry.cta_amount - income_statement_impact;
374
375 let breakdown = vec![
376 CTABreakdownItem {
377 description: "Opening net assets rate change".to_string(),
378 local_amount: entry.opening_net_assets_local,
379 rate: entry.closing_rate - entry.opening_rate,
380 group_amount: rate_change_impact,
381 cta_impact: rate_change_impact,
382 },
383 CTABreakdownItem {
384 description: "Net income translation difference".to_string(),
385 local_amount: entry.net_income_local,
386 rate: entry.closing_rate - entry.average_rate,
387 group_amount: income_statement_impact,
388 cta_impact: income_statement_impact,
389 },
390 ];
391
392 Self {
393 entry,
394 balance_sheet_impact,
395 income_statement_impact,
396 rate_change_impact,
397 breakdown,
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use datasynth_core::models::{FxRate, RateType};
406 use rust_decimal_macros::dec;
407
408 #[test]
409 fn test_generate_cta() {
410 let mut generator = CTAGenerator::new(CTAGeneratorConfig::default());
411
412 let mut rate_table = FxRateTable::new("USD");
413 rate_table.add_rate(FxRate::new(
414 "EUR",
415 "USD",
416 RateType::Closing,
417 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
418 dec!(1.12),
419 "TEST",
420 ));
421 rate_table.add_rate(FxRate::new(
422 "EUR",
423 "USD",
424 RateType::Average,
425 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
426 dec!(1.10),
427 "TEST",
428 ));
429
430 let (cta, je) = generator.generate_cta(
431 "1200",
432 "EUR",
433 2024,
434 12,
435 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
436 dec!(1000000), dec!(1100000), dec!(100000), &rate_table,
440 Some(dec!(1.08)), );
442
443 assert!(je.is_balanced());
444 assert_eq!(cta.closing_rate, dec!(1.12));
445 assert_eq!(cta.average_rate, dec!(1.10));
446
447 assert_eq!(cta.cta_amount, dec!(42000));
450 }
451
452 #[test]
453 fn test_cta_summary() {
454 let entries = vec![
455 CTAEntry {
456 entry_id: "CTA-001".to_string(),
457 company_code: "1200".to_string(),
458 local_currency: "EUR".to_string(),
459 group_currency: "USD".to_string(),
460 fiscal_year: 2024,
461 fiscal_period: 12,
462 period_end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
463 cta_amount: dec!(42000),
464 opening_rate: dec!(1.08),
465 closing_rate: dec!(1.12),
466 average_rate: dec!(1.10),
467 opening_net_assets_local: dec!(1000000),
468 closing_net_assets_local: dec!(1100000),
469 net_income_local: dec!(100000),
470 components: Vec::new(),
471 },
472 CTAEntry {
473 entry_id: "CTA-002".to_string(),
474 company_code: "1300".to_string(),
475 local_currency: "GBP".to_string(),
476 group_currency: "USD".to_string(),
477 fiscal_year: 2024,
478 fiscal_period: 12,
479 period_end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
480 cta_amount: dec!(-15000),
481 opening_rate: dec!(1.30),
482 closing_rate: dec!(1.27),
483 average_rate: dec!(1.28),
484 opening_net_assets_local: dec!(500000),
485 closing_net_assets_local: dec!(550000),
486 net_income_local: dec!(50000),
487 components: Vec::new(),
488 },
489 ];
490
491 let summary = CTASummary::from_entries(
492 entries,
493 2024,
494 12,
495 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
496 "USD".to_string(),
497 );
498
499 assert_eq!(summary.total_cta, dec!(27000)); assert_eq!(summary.cta_by_currency.get("EUR"), Some(&dec!(42000)));
501 assert_eq!(summary.cta_by_currency.get("GBP"), Some(&dec!(-15000)));
502 }
503}