1use chrono::NaiveDate;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct FiscalPeriod {
17 pub year: i32,
19 pub period: u8,
21 pub start_date: NaiveDate,
23 pub end_date: NaiveDate,
25 pub period_type: FiscalPeriodType,
27 pub is_year_end: bool,
29 pub status: PeriodStatus,
31}
32
33impl FiscalPeriod {
34 pub fn monthly(year: i32, month: u8) -> Self {
36 let start_date = NaiveDate::from_ymd_opt(year, month as u32, 1).unwrap();
37 let end_date = if month == 12 {
38 NaiveDate::from_ymd_opt(year + 1, 1, 1)
39 .unwrap()
40 .pred_opt()
41 .unwrap()
42 } else {
43 NaiveDate::from_ymd_opt(year, month as u32 + 1, 1)
44 .unwrap()
45 .pred_opt()
46 .unwrap()
47 };
48
49 Self {
50 year,
51 period: month,
52 start_date,
53 end_date,
54 period_type: FiscalPeriodType::Monthly,
55 is_year_end: month == 12,
56 status: PeriodStatus::Open,
57 }
58 }
59
60 pub fn quarterly(year: i32, quarter: u8) -> Self {
62 let start_month = (quarter - 1) * 3 + 1;
63 let end_month = quarter * 3;
64
65 let start_date = NaiveDate::from_ymd_opt(year, start_month as u32, 1).unwrap();
66 let end_date = if end_month == 12 {
67 NaiveDate::from_ymd_opt(year + 1, 1, 1)
68 .unwrap()
69 .pred_opt()
70 .unwrap()
71 } else {
72 NaiveDate::from_ymd_opt(year, end_month as u32 + 1, 1)
73 .unwrap()
74 .pred_opt()
75 .unwrap()
76 };
77
78 Self {
79 year,
80 period: quarter,
81 start_date,
82 end_date,
83 period_type: FiscalPeriodType::Quarterly,
84 is_year_end: quarter == 4,
85 status: PeriodStatus::Open,
86 }
87 }
88
89 pub fn days(&self) -> i64 {
91 (self.end_date - self.start_date).num_days() + 1
92 }
93
94 pub fn key(&self) -> String {
96 format!("{}-{:02}", self.year, self.period)
97 }
98
99 pub fn contains(&self, date: NaiveDate) -> bool {
101 date >= self.start_date && date <= self.end_date
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub enum FiscalPeriodType {
108 Monthly,
110 Quarterly,
112 Special,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118pub enum PeriodStatus {
119 Open,
121 SoftClosed,
123 Closed,
125 Locked,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Hash)]
131pub enum CloseTask {
132 RunDepreciation,
134 PostInventoryRevaluation,
136 ReconcileArToGl,
138 ReconcileApToGl,
140 ReconcileFaToGl,
142 ReconcileInventoryToGl,
144 PostAccruedExpenses,
146 PostAccruedRevenue,
148 PostPrepaidAmortization,
150 AllocateCorporateOverhead,
152 PostIntercompanySettlements,
154 RevalueForeignCurrency,
156 CalculateTaxProvision,
158 TranslateForeignSubsidiaries,
160 EliminateIntercompany,
162 GenerateTrialBalance,
164 GenerateFinancialStatements,
166 CloseIncomeStatement,
168 PostRetainedEarningsRollforward,
170 Custom(String),
172}
173
174impl CloseTask {
175 pub fn is_year_end_only(&self) -> bool {
177 matches!(
178 self,
179 CloseTask::CloseIncomeStatement | CloseTask::PostRetainedEarningsRollforward
180 )
181 }
182
183 pub fn name(&self) -> &str {
185 match self {
186 CloseTask::RunDepreciation => "Run Depreciation",
187 CloseTask::PostInventoryRevaluation => "Post Inventory Revaluation",
188 CloseTask::ReconcileArToGl => "Reconcile AR to GL",
189 CloseTask::ReconcileApToGl => "Reconcile AP to GL",
190 CloseTask::ReconcileFaToGl => "Reconcile FA to GL",
191 CloseTask::ReconcileInventoryToGl => "Reconcile Inventory to GL",
192 CloseTask::PostAccruedExpenses => "Post Accrued Expenses",
193 CloseTask::PostAccruedRevenue => "Post Accrued Revenue",
194 CloseTask::PostPrepaidAmortization => "Post Prepaid Amortization",
195 CloseTask::AllocateCorporateOverhead => "Allocate Corporate Overhead",
196 CloseTask::PostIntercompanySettlements => "Post IC Settlements",
197 CloseTask::RevalueForeignCurrency => "Revalue Foreign Currency",
198 CloseTask::CalculateTaxProvision => "Calculate Tax Provision",
199 CloseTask::TranslateForeignSubsidiaries => "Translate Foreign Subs",
200 CloseTask::EliminateIntercompany => "Eliminate Intercompany",
201 CloseTask::GenerateTrialBalance => "Generate Trial Balance",
202 CloseTask::GenerateFinancialStatements => "Generate Financials",
203 CloseTask::CloseIncomeStatement => "Close Income Statement",
204 CloseTask::PostRetainedEarningsRollforward => "Post RE Rollforward",
205 CloseTask::Custom(name) => name,
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum CloseTaskStatus {
213 Pending,
215 InProgress,
217 Completed,
219 CompletedWithWarnings(Vec<String>),
221 Failed(String),
223 Skipped(String),
225}
226
227#[derive(Debug, Clone)]
229pub struct CloseTaskResult {
230 pub task: CloseTask,
232 pub company_code: String,
234 pub fiscal_period: FiscalPeriod,
236 pub status: CloseTaskStatus,
238 pub started_at: Option<NaiveDate>,
240 pub completed_at: Option<NaiveDate>,
242 pub journal_entries_created: u32,
244 pub total_amount: Decimal,
246 pub notes: Vec<String>,
248}
249
250impl CloseTaskResult {
251 pub fn new(task: CloseTask, company_code: String, fiscal_period: FiscalPeriod) -> Self {
253 Self {
254 task,
255 company_code,
256 fiscal_period,
257 status: CloseTaskStatus::Pending,
258 started_at: None,
259 completed_at: None,
260 journal_entries_created: 0,
261 total_amount: Decimal::ZERO,
262 notes: Vec::new(),
263 }
264 }
265
266 pub fn is_success(&self) -> bool {
268 matches!(
269 self.status,
270 CloseTaskStatus::Completed | CloseTaskStatus::CompletedWithWarnings(_)
271 )
272 }
273}
274
275#[derive(Debug, Clone)]
277pub struct AccrualDefinition {
278 pub accrual_id: String,
280 pub company_code: String,
282 pub description: String,
284 pub accrual_type: AccrualType,
286 pub expense_revenue_account: String,
288 pub accrual_account: String,
290 pub calculation_method: AccrualCalculationMethod,
292 pub fixed_amount: Option<Decimal>,
294 pub percentage_rate: Option<Decimal>,
296 pub base_account: Option<String>,
298 pub frequency: AccrualFrequency,
300 pub auto_reverse: bool,
302 pub cost_center: Option<String>,
304 pub is_active: bool,
306 pub effective_from: NaiveDate,
308 pub effective_to: Option<NaiveDate>,
310}
311
312impl AccrualDefinition {
313 pub fn new(
315 accrual_id: String,
316 company_code: String,
317 description: String,
318 accrual_type: AccrualType,
319 expense_revenue_account: String,
320 accrual_account: String,
321 ) -> Self {
322 Self {
323 accrual_id,
324 company_code,
325 description,
326 accrual_type,
327 expense_revenue_account,
328 accrual_account,
329 calculation_method: AccrualCalculationMethod::FixedAmount,
330 fixed_amount: None,
331 percentage_rate: None,
332 base_account: None,
333 frequency: AccrualFrequency::Monthly,
334 auto_reverse: true,
335 cost_center: None,
336 is_active: true,
337 effective_from: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
338 effective_to: None,
339 }
340 }
341
342 pub fn with_fixed_amount(mut self, amount: Decimal) -> Self {
344 self.calculation_method = AccrualCalculationMethod::FixedAmount;
345 self.fixed_amount = Some(amount);
346 self
347 }
348
349 pub fn with_percentage(mut self, rate: Decimal, base_account: &str) -> Self {
351 self.calculation_method = AccrualCalculationMethod::PercentageOfBase;
352 self.percentage_rate = Some(rate);
353 self.base_account = Some(base_account.to_string());
354 self
355 }
356
357 pub fn is_effective_on(&self, date: NaiveDate) -> bool {
359 if !self.is_active {
360 return false;
361 }
362 if date < self.effective_from {
363 return false;
364 }
365 if let Some(end) = self.effective_to {
366 if date > end {
367 return false;
368 }
369 }
370 true
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum AccrualType {
377 AccruedExpense,
379 AccruedRevenue,
381 PrepaidExpense,
383 DeferredRevenue,
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum AccrualCalculationMethod {
390 FixedAmount,
392 PercentageOfBase,
394 DaysBased,
396 Manual,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum AccrualFrequency {
403 Monthly,
405 Quarterly,
407 Annually,
409}
410
411#[derive(Debug, Clone)]
413pub struct OverheadAllocation {
414 pub allocation_id: String,
416 pub source_company: String,
418 pub source_cost_center: String,
420 pub source_account: String,
422 pub allocation_basis: AllocationBasis,
424 pub targets: Vec<AllocationTarget>,
426 pub description: String,
428 pub is_active: bool,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
434pub enum AllocationBasis {
435 Revenue,
437 Headcount,
439 DirectCosts,
441 SquareFootage,
443 FixedPercentage,
445 Custom(String),
447}
448
449#[derive(Debug, Clone)]
451pub struct AllocationTarget {
452 pub company_code: String,
454 pub cost_center: String,
456 pub account: String,
458 pub percentage: Option<Decimal>,
460 pub driver_value: Option<Decimal>,
462}
463
464#[derive(Debug, Clone)]
466pub struct CloseSchedule {
467 pub schedule_id: String,
469 pub company_code: String,
471 pub period_type: FiscalPeriodType,
473 pub tasks: Vec<ScheduledCloseTask>,
475 pub is_year_end: bool,
477}
478
479impl CloseSchedule {
480 pub fn standard_monthly(company_code: &str) -> Self {
482 Self {
483 schedule_id: format!("MONTHLY-{}", company_code),
484 company_code: company_code.to_string(),
485 period_type: FiscalPeriodType::Monthly,
486 tasks: vec![
487 ScheduledCloseTask::new(CloseTask::RunDepreciation, 1),
488 ScheduledCloseTask::new(CloseTask::PostInventoryRevaluation, 2),
489 ScheduledCloseTask::new(CloseTask::PostAccruedExpenses, 3),
490 ScheduledCloseTask::new(CloseTask::PostAccruedRevenue, 4),
491 ScheduledCloseTask::new(CloseTask::PostPrepaidAmortization, 5),
492 ScheduledCloseTask::new(CloseTask::RevalueForeignCurrency, 6),
493 ScheduledCloseTask::new(CloseTask::ReconcileArToGl, 7),
494 ScheduledCloseTask::new(CloseTask::ReconcileApToGl, 8),
495 ScheduledCloseTask::new(CloseTask::ReconcileFaToGl, 9),
496 ScheduledCloseTask::new(CloseTask::ReconcileInventoryToGl, 10),
497 ScheduledCloseTask::new(CloseTask::PostIntercompanySettlements, 11),
498 ScheduledCloseTask::new(CloseTask::AllocateCorporateOverhead, 12),
499 ScheduledCloseTask::new(CloseTask::TranslateForeignSubsidiaries, 13),
500 ScheduledCloseTask::new(CloseTask::EliminateIntercompany, 14),
501 ScheduledCloseTask::new(CloseTask::GenerateTrialBalance, 15),
502 ],
503 is_year_end: false,
504 }
505 }
506
507 pub fn year_end(company_code: &str) -> Self {
509 let mut schedule = Self::standard_monthly(company_code);
510 schedule.schedule_id = format!("YEAREND-{}", company_code);
511 schedule.is_year_end = true;
512
513 let next_seq = schedule.tasks.len() as u32 + 1;
515 schedule.tasks.push(ScheduledCloseTask::new(
516 CloseTask::CalculateTaxProvision,
517 next_seq,
518 ));
519 schedule.tasks.push(ScheduledCloseTask::new(
520 CloseTask::CloseIncomeStatement,
521 next_seq + 1,
522 ));
523 schedule.tasks.push(ScheduledCloseTask::new(
524 CloseTask::PostRetainedEarningsRollforward,
525 next_seq + 2,
526 ));
527 schedule.tasks.push(ScheduledCloseTask::new(
528 CloseTask::GenerateFinancialStatements,
529 next_seq + 3,
530 ));
531
532 schedule
533 }
534}
535
536#[derive(Debug, Clone)]
538pub struct ScheduledCloseTask {
539 pub task: CloseTask,
541 pub sequence: u32,
543 pub depends_on: Vec<CloseTask>,
545 pub is_mandatory: bool,
547 pub can_parallelize: bool,
549}
550
551impl ScheduledCloseTask {
552 pub fn new(task: CloseTask, sequence: u32) -> Self {
554 Self {
555 task,
556 sequence,
557 depends_on: Vec::new(),
558 is_mandatory: true,
559 can_parallelize: false,
560 }
561 }
562
563 pub fn depends_on(mut self, task: CloseTask) -> Self {
565 self.depends_on.push(task);
566 self
567 }
568
569 pub fn optional(mut self) -> Self {
571 self.is_mandatory = false;
572 self
573 }
574
575 pub fn parallelizable(mut self) -> Self {
577 self.can_parallelize = true;
578 self
579 }
580}
581
582#[derive(Debug, Clone)]
584pub struct YearEndClosingSpec {
585 pub company_code: String,
587 pub fiscal_year: i32,
589 pub revenue_accounts: Vec<String>,
591 pub expense_accounts: Vec<String>,
593 pub income_summary_account: String,
595 pub retained_earnings_account: String,
597 pub dividend_account: Option<String>,
599}
600
601impl Default for YearEndClosingSpec {
602 fn default() -> Self {
603 Self {
604 company_code: String::new(),
605 fiscal_year: 0,
606 revenue_accounts: vec!["4".to_string()], expense_accounts: vec!["5".to_string(), "6".to_string()], income_summary_account: "3500".to_string(),
609 retained_earnings_account: "3300".to_string(),
610 dividend_account: Some("3400".to_string()),
611 }
612 }
613}
614
615#[derive(Debug, Clone)]
617pub struct TaxProvisionInput {
618 pub company_code: String,
620 pub fiscal_year: i32,
622 pub pretax_income: Decimal,
624 pub permanent_differences: Vec<TaxAdjustment>,
626 pub temporary_differences: Vec<TaxAdjustment>,
628 pub statutory_rate: Decimal,
630 pub tax_credits: Decimal,
632 pub prior_year_adjustment: Decimal,
634}
635
636#[derive(Debug, Clone)]
638pub struct TaxAdjustment {
639 pub description: String,
641 pub amount: Decimal,
643 pub is_addition: bool,
645}
646
647#[derive(Debug, Clone)]
649pub struct TaxProvisionResult {
650 pub company_code: String,
652 pub fiscal_year: i32,
654 pub pretax_income: Decimal,
656 pub permanent_differences: Decimal,
658 pub taxable_income: Decimal,
660 pub current_tax_expense: Decimal,
662 pub deferred_tax_expense: Decimal,
664 pub total_tax_expense: Decimal,
666 pub effective_rate: Decimal,
668}
669
670impl TaxProvisionResult {
671 pub fn calculate(input: &TaxProvisionInput) -> Self {
673 let permanent_diff: Decimal = input
674 .permanent_differences
675 .iter()
676 .map(|d| if d.is_addition { d.amount } else { -d.amount })
677 .sum();
678
679 let temporary_diff: Decimal = input
680 .temporary_differences
681 .iter()
682 .map(|d| if d.is_addition { d.amount } else { -d.amount })
683 .sum();
684
685 let taxable_income = input.pretax_income + permanent_diff;
686 let current_tax = (taxable_income * input.statutory_rate / dec!(100)).round_dp(2);
687 let deferred_tax = (temporary_diff * input.statutory_rate / dec!(100)).round_dp(2);
688
689 let total_tax =
690 current_tax + deferred_tax - input.tax_credits + input.prior_year_adjustment;
691
692 let effective_rate = if input.pretax_income != Decimal::ZERO {
693 (total_tax / input.pretax_income * dec!(100)).round_dp(2)
694 } else {
695 Decimal::ZERO
696 };
697
698 Self {
699 company_code: input.company_code.clone(),
700 fiscal_year: input.fiscal_year,
701 pretax_income: input.pretax_income,
702 permanent_differences: permanent_diff,
703 taxable_income,
704 current_tax_expense: current_tax,
705 deferred_tax_expense: deferred_tax,
706 total_tax_expense: total_tax,
707 effective_rate,
708 }
709 }
710}
711
712#[derive(Debug, Clone)]
714pub struct PeriodCloseRun {
715 pub run_id: String,
717 pub company_code: String,
719 pub fiscal_period: FiscalPeriod,
721 pub status: PeriodCloseStatus,
723 pub task_results: Vec<CloseTaskResult>,
725 pub started_at: Option<NaiveDate>,
727 pub completed_at: Option<NaiveDate>,
729 pub total_journal_entries: u32,
731 pub errors: Vec<String>,
733}
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub enum PeriodCloseStatus {
738 NotStarted,
740 InProgress,
742 Completed,
744 CompletedWithErrors,
746 Failed,
748}
749
750impl PeriodCloseRun {
751 pub fn new(run_id: String, company_code: String, fiscal_period: FiscalPeriod) -> Self {
753 Self {
754 run_id,
755 company_code,
756 fiscal_period,
757 status: PeriodCloseStatus::NotStarted,
758 task_results: Vec::new(),
759 started_at: None,
760 completed_at: None,
761 total_journal_entries: 0,
762 errors: Vec::new(),
763 }
764 }
765
766 pub fn is_success(&self) -> bool {
768 self.status == PeriodCloseStatus::Completed
769 }
770
771 pub fn failed_task_count(&self) -> usize {
773 self.task_results
774 .iter()
775 .filter(|r| matches!(r.status, CloseTaskStatus::Failed(_)))
776 .count()
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn test_fiscal_period_monthly() {
786 let period = FiscalPeriod::monthly(2024, 1);
787 assert_eq!(
788 period.start_date,
789 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
790 );
791 assert_eq!(
792 period.end_date,
793 NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()
794 );
795 assert_eq!(period.days(), 31);
796 assert!(!period.is_year_end);
797
798 let dec_period = FiscalPeriod::monthly(2024, 12);
799 assert!(dec_period.is_year_end);
800 }
801
802 #[test]
803 fn test_fiscal_period_quarterly() {
804 let q1 = FiscalPeriod::quarterly(2024, 1);
805 assert_eq!(q1.start_date, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
806 assert_eq!(q1.end_date, NaiveDate::from_ymd_opt(2024, 3, 31).unwrap());
807
808 let q4 = FiscalPeriod::quarterly(2024, 4);
809 assert!(q4.is_year_end);
810 }
811
812 #[test]
813 fn test_close_schedule() {
814 let schedule = CloseSchedule::standard_monthly("1000");
815 assert!(!schedule.is_year_end);
816 assert!(!schedule.tasks.is_empty());
817
818 let year_end = CloseSchedule::year_end("1000");
819 assert!(year_end.is_year_end);
820 assert!(year_end.tasks.len() > schedule.tasks.len());
821 }
822
823 #[test]
824 fn test_tax_provision() {
825 let input = TaxProvisionInput {
826 company_code: "1000".to_string(),
827 fiscal_year: 2024,
828 pretax_income: dec!(1000000),
829 permanent_differences: vec![TaxAdjustment {
830 description: "Meals & Entertainment".to_string(),
831 amount: dec!(10000),
832 is_addition: true,
833 }],
834 temporary_differences: vec![TaxAdjustment {
835 description: "Depreciation Timing".to_string(),
836 amount: dec!(50000),
837 is_addition: false,
838 }],
839 statutory_rate: dec!(21),
840 tax_credits: dec!(5000),
841 prior_year_adjustment: Decimal::ZERO,
842 };
843
844 let result = TaxProvisionResult::calculate(&input);
845 assert_eq!(result.taxable_income, dec!(1010000)); assert!(result.current_tax_expense > Decimal::ZERO);
847 }
848}