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