Skip to main content

datasynth_generators/period_close/
year_end.rs

1//! Year-end closing entry generator.
2
3use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use std::collections::HashMap;
7
8use datasynth_core::accounts::{equity_accounts, tax_accounts};
9use datasynth_core::models::{
10    JournalEntry, JournalEntryLine, TaxAdjustment, TaxProvisionInput, TaxProvisionResult,
11    YearEndClosingSpec,
12};
13
14/// Configuration for year-end closing.
15#[derive(Debug, Clone)]
16pub struct YearEndCloseConfig {
17    /// Income summary account.
18    pub income_summary_account: String,
19    /// Retained earnings account.
20    pub retained_earnings_account: String,
21    /// Dividend declared account.
22    pub dividend_account: String,
23    /// Current tax payable account.
24    pub current_tax_payable_account: String,
25    /// Deferred tax liability account.
26    pub deferred_tax_liability_account: String,
27    /// Deferred tax asset account.
28    pub deferred_tax_asset_account: String,
29    /// Tax expense account.
30    pub tax_expense_account: String,
31    /// Statutory tax rate.
32    pub statutory_tax_rate: Decimal,
33}
34
35impl Default for YearEndCloseConfig {
36    fn default() -> Self {
37        Self {
38            income_summary_account: equity_accounts::INCOME_SUMMARY.to_string(),
39            retained_earnings_account: equity_accounts::RETAINED_EARNINGS.to_string(),
40            dividend_account: equity_accounts::DIVIDENDS_PAID.to_string(),
41            current_tax_payable_account: tax_accounts::SALES_TAX_PAYABLE.to_string(),
42            deferred_tax_liability_account: tax_accounts::DEFERRED_TAX_LIABILITY.to_string(),
43            deferred_tax_asset_account: tax_accounts::DEFERRED_TAX_ASSET.to_string(),
44            tax_expense_account: tax_accounts::TAX_EXPENSE.to_string(),
45            statutory_tax_rate: dec!(21),
46        }
47    }
48}
49
50impl From<&datasynth_core::FrameworkAccounts> for YearEndCloseConfig {
51    fn from(fa: &datasynth_core::FrameworkAccounts) -> Self {
52        Self {
53            income_summary_account: fa.income_summary.clone(),
54            retained_earnings_account: fa.retained_earnings.clone(),
55            dividend_account: fa.dividends_paid.clone(),
56            current_tax_payable_account: fa.sales_tax_payable.clone(),
57            deferred_tax_liability_account: fa.deferred_tax_liability.clone(),
58            deferred_tax_asset_account: fa.deferred_tax_asset.clone(),
59            tax_expense_account: fa.tax_expense.clone(),
60            ..Default::default()
61        }
62    }
63}
64
65/// Generator for year-end closing entries.
66pub struct YearEndCloseGenerator {
67    config: YearEndCloseConfig,
68    entry_counter: u64,
69}
70
71impl YearEndCloseGenerator {
72    /// Creates a new year-end close generator.
73    pub fn new(config: YearEndCloseConfig) -> Self {
74        Self {
75            config,
76            entry_counter: 0,
77        }
78    }
79
80    /// Generates the complete year-end closing entries.
81    pub fn generate_year_end_close(
82        &mut self,
83        company_code: &str,
84        fiscal_year: i32,
85        trial_balance: &HashMap<String, Decimal>,
86        spec: &YearEndClosingSpec,
87    ) -> YearEndCloseResult {
88        let closing_date =
89            NaiveDate::from_ymd_opt(fiscal_year, 12, 31).expect("valid year-end date");
90
91        let mut result = YearEndCloseResult {
92            company_code: company_code.to_string(),
93            fiscal_year,
94            closing_entries: Vec::new(),
95            total_revenue_closed: Decimal::ZERO,
96            total_expense_closed: Decimal::ZERO,
97            net_income: Decimal::ZERO,
98            retained_earnings_impact: Decimal::ZERO,
99        };
100
101        // Step 1: Close revenue accounts to income summary
102        let (revenue_je, revenue_total) =
103            self.close_revenue_accounts(company_code, closing_date, trial_balance, spec);
104        result.total_revenue_closed = revenue_total;
105        result.closing_entries.push(revenue_je);
106
107        // Step 2: Close expense accounts to income summary
108        let (expense_je, expense_total) =
109            self.close_expense_accounts(company_code, closing_date, trial_balance, spec);
110        result.total_expense_closed = expense_total;
111        result.closing_entries.push(expense_je);
112
113        // Step 3: Close income summary to retained earnings
114        let net_income = revenue_total - expense_total;
115        result.net_income = net_income;
116
117        let income_summary_je = self.close_income_summary(company_code, closing_date, net_income);
118        result.closing_entries.push(income_summary_je);
119
120        // Step 4: Close dividends to retained earnings (if applicable)
121        if let Some(dividend_account) = &spec.dividend_account {
122            if let Some(dividend_balance) = trial_balance.get(dividend_account) {
123                if *dividend_balance != Decimal::ZERO {
124                    let dividend_je = self.close_dividends(
125                        company_code,
126                        closing_date,
127                        *dividend_balance,
128                        dividend_account,
129                    );
130                    result.closing_entries.push(dividend_je);
131                    result.retained_earnings_impact = net_income - *dividend_balance;
132                } else {
133                    result.retained_earnings_impact = net_income;
134                }
135            } else {
136                result.retained_earnings_impact = net_income;
137            }
138        } else {
139            result.retained_earnings_impact = net_income;
140        }
141
142        result
143    }
144
145    /// Closes revenue accounts to income summary.
146    fn close_revenue_accounts(
147        &mut self,
148        company_code: &str,
149        closing_date: NaiveDate,
150        trial_balance: &HashMap<String, Decimal>,
151        spec: &YearEndClosingSpec,
152    ) -> (JournalEntry, Decimal) {
153        self.entry_counter += 1;
154        let doc_number = format!("YECL-REV-{:08}", self.entry_counter);
155
156        let mut je = JournalEntry::new_simple(
157            doc_number.clone(),
158            company_code.to_string(),
159            closing_date,
160            "Year-End Close: Revenue to Income Summary".to_string(),
161        );
162
163        let mut line_num = 1u32;
164        let mut total_revenue = Decimal::ZERO;
165
166        // Find all revenue accounts (accounts starting with prefixes in spec)
167        for (account, balance) in trial_balance {
168            let is_revenue = spec
169                .revenue_accounts
170                .iter()
171                .any(|prefix| account.starts_with(prefix));
172
173            if is_revenue && *balance != Decimal::ZERO {
174                // Revenue accounts have credit balances, so debit to close
175                je.add_line(JournalEntryLine {
176                    line_number: line_num,
177                    gl_account: account.clone(),
178                    debit_amount: *balance,
179                    reference: Some(doc_number.clone()),
180                    text: Some("Year-end close".to_string()),
181                    ..Default::default()
182                });
183                line_num += 1;
184                total_revenue += *balance;
185            }
186        }
187
188        // Credit Income Summary
189        if total_revenue != Decimal::ZERO {
190            je.add_line(JournalEntryLine {
191                line_number: line_num,
192                gl_account: spec.income_summary_account.clone(),
193                credit_amount: total_revenue,
194                reference: Some(doc_number.clone()),
195                text: Some("Revenue closed".to_string()),
196                ..Default::default()
197            });
198        }
199
200        (je, total_revenue)
201    }
202
203    /// Closes expense accounts to income summary.
204    fn close_expense_accounts(
205        &mut self,
206        company_code: &str,
207        closing_date: NaiveDate,
208        trial_balance: &HashMap<String, Decimal>,
209        spec: &YearEndClosingSpec,
210    ) -> (JournalEntry, Decimal) {
211        self.entry_counter += 1;
212        let doc_number = format!("YECL-EXP-{:08}", self.entry_counter);
213
214        let mut je = JournalEntry::new_simple(
215            doc_number.clone(),
216            company_code.to_string(),
217            closing_date,
218            "Year-End Close: Expenses to Income Summary".to_string(),
219        );
220
221        let mut line_num = 1u32;
222        let mut total_expenses = Decimal::ZERO;
223
224        // Debit Income Summary first
225        // We'll update this amount after calculating total expenses
226
227        // Find all expense accounts
228        let mut expense_lines = Vec::new();
229        for (account, balance) in trial_balance {
230            let is_expense = spec
231                .expense_accounts
232                .iter()
233                .any(|prefix| account.starts_with(prefix));
234
235            if is_expense && *balance != Decimal::ZERO {
236                expense_lines.push((account.clone(), *balance));
237                total_expenses += *balance;
238            }
239        }
240
241        // Debit Income Summary
242        if total_expenses != Decimal::ZERO {
243            je.add_line(JournalEntryLine {
244                line_number: line_num,
245                gl_account: spec.income_summary_account.clone(),
246                debit_amount: total_expenses,
247                reference: Some(doc_number.clone()),
248                text: Some("Expenses closed".to_string()),
249                ..Default::default()
250            });
251            line_num += 1;
252        }
253
254        // Credit each expense account
255        for (account, balance) in expense_lines {
256            je.add_line(JournalEntryLine {
257                line_number: line_num,
258                gl_account: account,
259                credit_amount: balance,
260                reference: Some(doc_number.clone()),
261                text: Some("Year-end close".to_string()),
262                ..Default::default()
263            });
264            line_num += 1;
265        }
266
267        (je, total_expenses)
268    }
269
270    /// Closes income summary to retained earnings.
271    fn close_income_summary(
272        &mut self,
273        company_code: &str,
274        closing_date: NaiveDate,
275        net_income: Decimal,
276    ) -> JournalEntry {
277        self.entry_counter += 1;
278        let doc_number = format!("YECL-IS-{:08}", self.entry_counter);
279
280        let mut je = JournalEntry::new_simple(
281            doc_number.clone(),
282            company_code.to_string(),
283            closing_date,
284            "Year-End Close: Income Summary to Retained Earnings".to_string(),
285        );
286
287        if net_income > Decimal::ZERO {
288            // Profit: Debit Income Summary, Credit Retained Earnings
289            je.add_line(JournalEntryLine {
290                line_number: 1,
291                gl_account: self.config.income_summary_account.clone(),
292                debit_amount: net_income,
293                reference: Some(doc_number.clone()),
294                text: Some("Net income transfer".to_string()),
295                ..Default::default()
296            });
297
298            je.add_line(JournalEntryLine {
299                line_number: 2,
300                gl_account: self.config.retained_earnings_account.clone(),
301                credit_amount: net_income,
302                reference: Some(doc_number.clone()),
303                text: Some("Net income for year".to_string()),
304                ..Default::default()
305            });
306        } else if net_income < Decimal::ZERO {
307            // Loss: Debit Retained Earnings, Credit Income Summary
308            let loss = net_income.abs();
309            je.add_line(JournalEntryLine {
310                line_number: 1,
311                gl_account: self.config.retained_earnings_account.clone(),
312                debit_amount: loss,
313                reference: Some(doc_number.clone()),
314                text: Some("Net loss for year".to_string()),
315                ..Default::default()
316            });
317
318            je.add_line(JournalEntryLine {
319                line_number: 2,
320                gl_account: self.config.income_summary_account.clone(),
321                credit_amount: loss,
322                reference: Some(doc_number.clone()),
323                text: Some("Net loss transfer".to_string()),
324                ..Default::default()
325            });
326        }
327
328        je
329    }
330
331    /// Closes dividends to retained earnings.
332    fn close_dividends(
333        &mut self,
334        company_code: &str,
335        closing_date: NaiveDate,
336        dividend_amount: Decimal,
337        dividend_account: &str,
338    ) -> JournalEntry {
339        self.entry_counter += 1;
340        let doc_number = format!("YECL-DIV-{:08}", self.entry_counter);
341
342        let mut je = JournalEntry::new_simple(
343            doc_number.clone(),
344            company_code.to_string(),
345            closing_date,
346            "Year-End Close: Dividends to Retained Earnings".to_string(),
347        );
348
349        // Debit Retained Earnings
350        je.add_line(JournalEntryLine {
351            line_number: 1,
352            gl_account: self.config.retained_earnings_account.clone(),
353            debit_amount: dividend_amount,
354            reference: Some(doc_number.clone()),
355            text: Some("Dividends declared".to_string()),
356            ..Default::default()
357        });
358
359        // Credit Dividends
360        je.add_line(JournalEntryLine {
361            line_number: 2,
362            gl_account: dividend_account.to_string(),
363            credit_amount: dividend_amount,
364            reference: Some(doc_number.clone()),
365            text: Some("Dividends closed".to_string()),
366            ..Default::default()
367        });
368
369        je
370    }
371
372    /// Generates tax provision entries.
373    pub fn generate_tax_provision(
374        &mut self,
375        company_code: &str,
376        fiscal_year: i32,
377        pretax_income: Decimal,
378        permanent_differences: Vec<TaxAdjustment>,
379        temporary_differences: Vec<TaxAdjustment>,
380    ) -> TaxProvisionGenerationResult {
381        let closing_date =
382            NaiveDate::from_ymd_opt(fiscal_year, 12, 31).expect("valid year-end date");
383
384        let input = TaxProvisionInput {
385            company_code: company_code.to_string(),
386            fiscal_year,
387            pretax_income,
388            permanent_differences,
389            temporary_differences,
390            statutory_rate: self.config.statutory_tax_rate,
391            tax_credits: Decimal::ZERO,
392            prior_year_adjustment: Decimal::ZERO,
393        };
394
395        let provision = TaxProvisionResult::calculate(&input);
396
397        // Generate journal entries
398        let mut entries = Vec::new();
399
400        // Entry 1: Current tax expense
401        if provision.current_tax_expense != Decimal::ZERO {
402            self.entry_counter += 1;
403            let mut je = JournalEntry::new_simple(
404                format!("TAX-CUR-{:08}", self.entry_counter),
405                company_code.to_string(),
406                closing_date,
407                "Current Income Tax Expense".to_string(),
408            );
409
410            je.add_line(JournalEntryLine {
411                line_number: 1,
412                gl_account: self.config.tax_expense_account.clone(),
413                debit_amount: provision.current_tax_expense,
414                text: Some("Current tax provision".to_string()),
415                ..Default::default()
416            });
417
418            je.add_line(JournalEntryLine {
419                line_number: 2,
420                gl_account: self.config.current_tax_payable_account.clone(),
421                credit_amount: provision.current_tax_expense,
422                ..Default::default()
423            });
424
425            entries.push(je);
426        }
427
428        // Entry 2: Deferred tax expense/benefit
429        if provision.deferred_tax_expense != Decimal::ZERO {
430            self.entry_counter += 1;
431            let mut je = JournalEntry::new_simple(
432                format!("TAX-DEF-{:08}", self.entry_counter),
433                company_code.to_string(),
434                closing_date,
435                "Deferred Income Tax".to_string(),
436            );
437
438            if provision.deferred_tax_expense > Decimal::ZERO {
439                // Deferred tax expense (increase in DTL or decrease in DTA)
440                je.add_line(JournalEntryLine {
441                    line_number: 1,
442                    gl_account: self.config.tax_expense_account.clone(),
443                    debit_amount: provision.deferred_tax_expense,
444                    text: Some("Deferred tax expense".to_string()),
445                    ..Default::default()
446                });
447
448                je.add_line(JournalEntryLine {
449                    line_number: 2,
450                    gl_account: self.config.deferred_tax_liability_account.clone(),
451                    credit_amount: provision.deferred_tax_expense,
452                    ..Default::default()
453                });
454            } else {
455                // Deferred tax benefit (increase in DTA or decrease in DTL)
456                let benefit = provision.deferred_tax_expense.abs();
457                je.add_line(JournalEntryLine {
458                    line_number: 1,
459                    gl_account: self.config.deferred_tax_asset_account.clone(),
460                    debit_amount: benefit,
461                    text: Some("Deferred tax benefit".to_string()),
462                    ..Default::default()
463                });
464
465                je.add_line(JournalEntryLine {
466                    line_number: 2,
467                    gl_account: self.config.tax_expense_account.clone(),
468                    credit_amount: benefit,
469                    ..Default::default()
470                });
471            }
472
473            entries.push(je);
474        }
475
476        TaxProvisionGenerationResult {
477            provision,
478            journal_entries: entries,
479        }
480    }
481}
482
483/// Result of year-end closing.
484#[derive(Debug, Clone)]
485pub struct YearEndCloseResult {
486    /// Company code.
487    pub company_code: String,
488    /// Fiscal year.
489    pub fiscal_year: i32,
490    /// Generated closing entries.
491    pub closing_entries: Vec<JournalEntry>,
492    /// Total revenue closed.
493    pub total_revenue_closed: Decimal,
494    /// Total expenses closed.
495    pub total_expense_closed: Decimal,
496    /// Net income (revenue - expenses).
497    pub net_income: Decimal,
498    /// Impact on retained earnings.
499    pub retained_earnings_impact: Decimal,
500}
501
502impl YearEndCloseResult {
503    /// Returns true if all entries are balanced.
504    pub fn all_entries_balanced(&self) -> bool {
505        self.closing_entries.iter().all(|je| je.is_balanced())
506    }
507}
508
509/// Result of tax provision generation.
510#[derive(Debug, Clone)]
511pub struct TaxProvisionGenerationResult {
512    /// Tax provision calculation result.
513    pub provision: TaxProvisionResult,
514    /// Generated journal entries.
515    pub journal_entries: Vec<JournalEntry>,
516}
517
518#[cfg(test)]
519#[allow(clippy::unwrap_used)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn test_year_end_close() {
525        let mut generator = YearEndCloseGenerator::new(YearEndCloseConfig::default());
526
527        let mut trial_balance = HashMap::new();
528        trial_balance.insert("4000".to_string(), dec!(500000)); // Revenue
529        trial_balance.insert("4100".to_string(), dec!(50000)); // Other Revenue
530        trial_balance.insert("5000".to_string(), dec!(300000)); // COGS
531        trial_balance.insert("6000".to_string(), dec!(100000)); // Operating Expenses
532
533        let spec = YearEndClosingSpec {
534            company_code: "1000".to_string(),
535            fiscal_year: 2024,
536            revenue_accounts: vec!["4".to_string()],
537            expense_accounts: vec!["5".to_string(), "6".to_string()],
538            income_summary_account: "3500".to_string(),
539            retained_earnings_account: "3300".to_string(),
540            dividend_account: None,
541        };
542
543        let result = generator.generate_year_end_close("1000", 2024, &trial_balance, &spec);
544
545        assert_eq!(result.total_revenue_closed, dec!(550000));
546        assert_eq!(result.total_expense_closed, dec!(400000));
547        assert_eq!(result.net_income, dec!(150000));
548        assert!(result.all_entries_balanced());
549    }
550
551    #[test]
552    fn test_tax_provision() {
553        let mut generator = YearEndCloseGenerator::new(YearEndCloseConfig::default());
554
555        let result = generator.generate_tax_provision(
556            "1000",
557            2024,
558            dec!(1000000),
559            vec![TaxAdjustment {
560                description: "Non-deductible expenses".to_string(),
561                amount: dec!(10000),
562                is_addition: true,
563            }],
564            vec![],
565        );
566
567        assert!(result.provision.current_tax_expense > Decimal::ZERO);
568        assert!(result.journal_entries.iter().all(|je| je.is_balanced()));
569    }
570}