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