Skip to main content

datasynth_generators/fx/
cta_generator.rs

1//! Currency Translation Adjustment (CTA) generator.
2//!
3//! Generates CTA entries for subsidiaries with foreign functional currencies.
4
5use 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/// Configuration for CTA generation.
14#[derive(Debug, Clone)]
15pub struct CTAGeneratorConfig {
16    /// Group (reporting) currency.
17    pub group_currency: String,
18    /// CTA account code for posting.
19    pub cta_account: String,
20    /// Accumulated CTA account (OCI).
21    pub accumulated_cta_account: String,
22    /// Whether to generate detailed CTA breakdown.
23    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
37/// Generator for Currency Translation Adjustment entries.
38pub struct CTAGenerator {
39    config: CTAGeneratorConfig,
40    cta_counter: u64,
41}
42
43impl CTAGenerator {
44    /// Creates a new CTA generator.
45    pub fn new(config: CTAGeneratorConfig) -> Self {
46        Self {
47            config,
48            cta_counter: 0,
49        }
50    }
51
52    /// Generates a CTA entry for a subsidiary for a period.
53    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        // Get rates
70        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        // Calculate CTA using current rate method
100        cta.calculate_current_rate_method();
101
102        // Generate journal entry
103        let je = self.generate_cta_journal_entry(&cta);
104
105        (cta, je)
106    }
107
108    /// Generates CTA from translated trial balance comparison.
109    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    /// Generates CTA entries for multiple subsidiaries.
151    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    /// Generates the journal entry for a CTA.
176    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            // CTA gain (credit to CTA, debit to plug)
189            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            // CTA loss (debit to CTA, credit to plug)
211            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/// Input data for subsidiary CTA calculation.
240#[derive(Debug, Clone)]
241pub struct SubsidiaryCTAInput {
242    /// Company code.
243    pub company_code: String,
244    /// Local (functional) currency.
245    pub local_currency: String,
246    /// Fiscal year.
247    pub fiscal_year: i32,
248    /// Fiscal period.
249    pub fiscal_period: u8,
250    /// Period end date.
251    pub period_end_date: NaiveDate,
252    /// Opening net assets in local currency.
253    pub opening_net_assets_local: Decimal,
254    /// Closing net assets in local currency.
255    pub closing_net_assets_local: Decimal,
256    /// Net income for the period in local currency.
257    pub net_income_local: Decimal,
258    /// Opening exchange rate (prior period closing rate).
259    pub opening_rate: Option<Decimal>,
260}
261
262/// Summary of CTA across all subsidiaries.
263#[derive(Debug, Clone)]
264pub struct CTASummary {
265    /// Fiscal year.
266    pub fiscal_year: i32,
267    /// Fiscal period.
268    pub fiscal_period: u8,
269    /// Period end date.
270    pub period_end_date: NaiveDate,
271    /// Group currency.
272    pub group_currency: String,
273    /// CTA entries by subsidiary.
274    pub entries: Vec<CTAEntry>,
275    /// Total CTA (sum across all subsidiaries).
276    pub total_cta: Decimal,
277    /// CTA by currency.
278    pub cta_by_currency: HashMap<String, Decimal>,
279}
280
281impl CTASummary {
282    /// Creates a new CTA summary from entries.
283    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    /// Returns a summary string.
311    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/// Detailed CTA analysis for a single subsidiary.
332#[derive(Debug, Clone)]
333pub struct CTAAnalysis {
334    /// CTA entry.
335    pub entry: CTAEntry,
336    /// Translation of balance sheet impact.
337    pub balance_sheet_impact: Decimal,
338    /// Translation of income statement impact.
339    pub income_statement_impact: Decimal,
340    /// Rate change impact on opening net assets.
341    pub rate_change_impact: Decimal,
342    /// Breakdown by component.
343    pub breakdown: Vec<CTABreakdownItem>,
344}
345
346/// CTA breakdown item for detailed analysis.
347#[derive(Debug, Clone)]
348pub struct CTABreakdownItem {
349    /// Description of the item.
350    pub description: String,
351    /// Local currency amount.
352    pub local_amount: Decimal,
353    /// Rate used.
354    pub rate: Decimal,
355    /// Group currency amount.
356    pub group_amount: Decimal,
357    /// Impact on CTA.
358    pub cta_impact: Decimal,
359}
360
361impl CTAAnalysis {
362    /// Creates a detailed CTA analysis from an entry.
363    pub fn from_entry(entry: CTAEntry) -> Self {
364        // Calculate impacts
365        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), // Opening net assets
437            dec!(1100000), // Closing net assets
438            dec!(100000),  // Net income
439            &rate_table,
440            Some(dec!(1.08)), // Opening rate
441        );
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        // CTA = 1,100,000 × 1.12 - 1,000,000 × 1.08 - 100,000 × 1.10
448        // CTA = 1,232,000 - 1,080,000 - 110,000 = 42,000
449        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)); // 42,000 - 15,000
500        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}