1use 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#[derive(Debug, Clone)]
16pub struct YearEndCloseConfig {
17 pub income_summary_account: String,
19 pub retained_earnings_account: String,
21 pub dividend_account: String,
23 pub current_tax_payable_account: String,
25 pub deferred_tax_liability_account: String,
27 pub deferred_tax_asset_account: String,
29 pub tax_expense_account: String,
31 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
50pub struct YearEndCloseGenerator {
52 config: YearEndCloseConfig,
53 entry_counter: u64,
54}
55
56impl YearEndCloseGenerator {
57 pub fn new(config: YearEndCloseConfig) -> Self {
59 Self {
60 config,
61 entry_counter: 0,
62 }
63 }
64
65 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut entries = Vec::new();
384
385 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 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 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 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#[derive(Debug, Clone)]
470pub struct YearEndCloseResult {
471 pub company_code: String,
473 pub fiscal_year: i32,
475 pub closing_entries: Vec<JournalEntry>,
477 pub total_revenue_closed: Decimal,
479 pub total_expense_closed: Decimal,
481 pub net_income: Decimal,
483 pub retained_earnings_impact: Decimal,
485}
486
487impl YearEndCloseResult {
488 pub fn all_entries_balanced(&self) -> bool {
490 self.closing_entries.iter().all(|je| je.is_balanced())
491 }
492}
493
494#[derive(Debug, Clone)]
496pub struct TaxProvisionGenerationResult {
497 pub provision: TaxProvisionResult,
499 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)); trial_balance.insert("4100".to_string(), dec!(50000)); trial_balance.insert("5000".to_string(), dec!(300000)); trial_balance.insert("6000".to_string(), dec!(100000)); 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}