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