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