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)]
403#[allow(clippy::unwrap_used)]
404mod tests {
405    use super::*;
406    use datasynth_core::models::{FxRate, RateType};
407    use rust_decimal_macros::dec;
408
409    #[test]
410    fn test_generate_cta() {
411        let mut generator = CTAGenerator::new(CTAGeneratorConfig::default());
412
413        let mut rate_table = FxRateTable::new("USD");
414        rate_table.add_rate(FxRate::new(
415            "EUR",
416            "USD",
417            RateType::Closing,
418            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
419            dec!(1.12),
420            "TEST",
421        ));
422        rate_table.add_rate(FxRate::new(
423            "EUR",
424            "USD",
425            RateType::Average,
426            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
427            dec!(1.10),
428            "TEST",
429        ));
430
431        let (cta, je) = generator.generate_cta(
432            "1200",
433            "EUR",
434            2024,
435            12,
436            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
437            dec!(1000000), // Opening net assets
438            dec!(1100000), // Closing net assets
439            dec!(100000),  // Net income
440            &rate_table,
441            Some(dec!(1.08)), // Opening rate
442        );
443
444        assert!(je.is_balanced());
445        assert_eq!(cta.closing_rate, dec!(1.12));
446        assert_eq!(cta.average_rate, dec!(1.10));
447
448        // CTA = 1,100,000 × 1.12 - 1,000,000 × 1.08 - 100,000 × 1.10
449        // CTA = 1,232,000 - 1,080,000 - 110,000 = 42,000
450        assert_eq!(cta.cta_amount, dec!(42000));
451    }
452
453    #[test]
454    fn test_cta_summary() {
455        let entries = vec![
456            CTAEntry {
457                entry_id: "CTA-001".to_string(),
458                company_code: "1200".to_string(),
459                local_currency: "EUR".to_string(),
460                group_currency: "USD".to_string(),
461                fiscal_year: 2024,
462                fiscal_period: 12,
463                period_end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
464                cta_amount: dec!(42000),
465                opening_rate: dec!(1.08),
466                closing_rate: dec!(1.12),
467                average_rate: dec!(1.10),
468                opening_net_assets_local: dec!(1000000),
469                closing_net_assets_local: dec!(1100000),
470                net_income_local: dec!(100000),
471                components: Vec::new(),
472            },
473            CTAEntry {
474                entry_id: "CTA-002".to_string(),
475                company_code: "1300".to_string(),
476                local_currency: "GBP".to_string(),
477                group_currency: "USD".to_string(),
478                fiscal_year: 2024,
479                fiscal_period: 12,
480                period_end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
481                cta_amount: dec!(-15000),
482                opening_rate: dec!(1.30),
483                closing_rate: dec!(1.27),
484                average_rate: dec!(1.28),
485                opening_net_assets_local: dec!(500000),
486                closing_net_assets_local: dec!(550000),
487                net_income_local: dec!(50000),
488                components: Vec::new(),
489            },
490        ];
491
492        let summary = CTASummary::from_entries(
493            entries,
494            2024,
495            12,
496            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
497            "USD".to_string(),
498        );
499
500        assert_eq!(summary.total_cta, dec!(27000)); // 42,000 - 15,000
501        assert_eq!(summary.cta_by_currency.get("EUR"), Some(&dec!(42000)));
502        assert_eq!(summary.cta_by_currency.get("GBP"), Some(&dec!(-15000)));
503    }
504}