1use 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#[derive(Debug, Clone)]
15pub struct YearEndCloseConfig {
16 pub income_summary_account: String,
18 pub retained_earnings_account: String,
20 pub dividend_account: String,
22 pub current_tax_payable_account: String,
24 pub deferred_tax_liability_account: String,
26 pub deferred_tax_asset_account: String,
28 pub tax_expense_account: String,
30 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
49pub struct YearEndCloseGenerator {
51 config: YearEndCloseConfig,
52 entry_counter: u64,
53}
54
55impl YearEndCloseGenerator {
56 pub fn new(config: YearEndCloseConfig) -> Self {
58 Self {
59 config,
60 entry_counter: 0,
61 }
62 }
63
64 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut entries = Vec::new();
381
382 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 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 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 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#[derive(Debug, Clone)]
467pub struct YearEndCloseResult {
468 pub company_code: String,
470 pub fiscal_year: i32,
472 pub closing_entries: Vec<JournalEntry>,
474 pub total_revenue_closed: Decimal,
476 pub total_expense_closed: Decimal,
478 pub net_income: Decimal,
480 pub retained_earnings_impact: Decimal,
482}
483
484impl YearEndCloseResult {
485 pub fn all_entries_balanced(&self) -> bool {
487 self.closing_entries.iter().all(|je| je.is_balanced())
488 }
489}
490
491#[derive(Debug, Clone)]
493pub struct TaxProvisionGenerationResult {
494 pub provision: TaxProvisionResult,
496 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)); 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 {
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}