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