Skip to main content

datasynth_core/models/
period_close.rs

1//! Period close models.
2//!
3//! This module provides models for fiscal period management and
4//! period-end close processes including:
5//! - Fiscal period definitions
6//! - Fiscal calendar types (calendar year, custom year start, 4-4-5, 13-period)
7//! - Close tasks and workflows
8//! - Accrual definitions and schedules
9//! - Year-end closing entries
10
11use chrono::{Datelike, NaiveDate};
12use rust_decimal::Decimal;
13use rust_decimal_macros::dec;
14use serde::{Deserialize, Serialize};
15
16/// Type of fiscal calendar used by the organization.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum FiscalCalendarType {
20    /// Standard calendar year (Jan 1 - Dec 31).
21    #[default]
22    CalendarYear,
23    /// Custom year start (e.g., July 1 for government fiscal years).
24    CustomYearStart {
25        /// Month the fiscal year starts (1-12).
26        start_month: u8,
27        /// Day the fiscal year starts (1-31).
28        start_day: u8,
29    },
30    /// 4-4-5 retail calendar (52/53 week years with 4-4-5, 4-5-4, or 5-4-4 pattern).
31    FourFourFive(FourFourFiveConfig),
32    /// 13-period calendar (13 equal 4-week periods).
33    ThirteenPeriod(ThirteenPeriodConfig),
34}
35
36/// Configuration for 4-4-5 retail calendar.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct FourFourFiveConfig {
39    /// Week pattern for each quarter.
40    pub pattern: WeekPattern,
41    /// Anchor point for determining fiscal year start.
42    pub anchor: FourFourFiveAnchor,
43    /// Where to place the leap week in 53-week years.
44    pub leap_week_placement: LeapWeekPlacement,
45}
46
47impl Default for FourFourFiveConfig {
48    fn default() -> Self {
49        Self {
50            pattern: WeekPattern::FourFourFive,
51            anchor: FourFourFiveAnchor::LastSaturdayOf(1), // Last Saturday of January
52            leap_week_placement: LeapWeekPlacement::Q4Period3,
53        }
54    }
55}
56
57/// Week pattern for 4-4-5 calendar quarters.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum WeekPattern {
61    /// 4 weeks, 4 weeks, 5 weeks per quarter.
62    FourFourFive,
63    /// 4 weeks, 5 weeks, 4 weeks per quarter.
64    FourFiveFour,
65    /// 5 weeks, 4 weeks, 4 weeks per quarter.
66    FiveFourFour,
67}
68
69impl WeekPattern {
70    /// Returns the number of weeks in each period of a quarter.
71    pub fn weeks_per_period(&self) -> [u8; 3] {
72        match self {
73            WeekPattern::FourFourFive => [4, 4, 5],
74            WeekPattern::FourFiveFour => [4, 5, 4],
75            WeekPattern::FiveFourFour => [5, 4, 4],
76        }
77    }
78}
79
80/// Anchor point for 4-4-5 fiscal year start.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "type", content = "month", rename_all = "snake_case")]
83pub enum FourFourFiveAnchor {
84    /// Fiscal year starts on the first Sunday of a month.
85    FirstSundayOf(u8),
86    /// Fiscal year starts on the last Saturday of a month.
87    LastSaturdayOf(u8),
88    /// Fiscal year ends on the Saturday nearest to a month end.
89    NearestSaturdayTo(u8),
90}
91
92/// Where to place the leap week in 53-week years.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum LeapWeekPlacement {
96    /// Add leap week to Q4 Period 3 (most common).
97    Q4Period3,
98    /// Add leap week to Q1 Period 1.
99    Q1Period1,
100}
101
102/// Configuration for 13-period calendar.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct ThirteenPeriodConfig {
105    /// First day of fiscal year (day of year, 1-366).
106    pub year_start_day: u16,
107    /// Month containing year start (for display purposes).
108    pub year_start_month: u8,
109}
110
111impl Default for ThirteenPeriodConfig {
112    fn default() -> Self {
113        Self {
114            year_start_day: 1,   // January 1
115            year_start_month: 1, // January
116        }
117    }
118}
119
120/// Fiscal calendar definition.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct FiscalCalendar {
123    /// Type of fiscal calendar.
124    pub calendar_type: FiscalCalendarType,
125    /// Name of the fiscal calendar (e.g., "US Federal", "Retail 445").
126    pub name: String,
127}
128
129impl Default for FiscalCalendar {
130    fn default() -> Self {
131        Self {
132            calendar_type: FiscalCalendarType::CalendarYear,
133            name: "Calendar Year".to_string(),
134        }
135    }
136}
137
138impl FiscalCalendar {
139    /// Creates a standard calendar year fiscal calendar.
140    pub fn calendar_year() -> Self {
141        Self::default()
142    }
143
144    /// Creates a fiscal calendar with custom year start.
145    pub fn custom_year_start(start_month: u8, start_day: u8) -> Self {
146        Self {
147            calendar_type: FiscalCalendarType::CustomYearStart {
148                start_month,
149                start_day,
150            },
151            name: format!("Fiscal Year ({})", month_name(start_month)),
152        }
153    }
154
155    /// Creates a 4-4-5 retail calendar.
156    pub fn four_four_five(config: FourFourFiveConfig) -> Self {
157        Self {
158            calendar_type: FiscalCalendarType::FourFourFive(config),
159            name: "Retail 4-4-5".to_string(),
160        }
161    }
162
163    /// Creates a 13-period calendar.
164    pub fn thirteen_period(config: ThirteenPeriodConfig) -> Self {
165        Self {
166            calendar_type: FiscalCalendarType::ThirteenPeriod(config),
167            name: "13-Period".to_string(),
168        }
169    }
170
171    /// Returns the fiscal year for a given date.
172    pub fn fiscal_year(&self, date: NaiveDate) -> i32 {
173        match &self.calendar_type {
174            FiscalCalendarType::CalendarYear => date.year(),
175            FiscalCalendarType::CustomYearStart {
176                start_month,
177                start_day,
178            } => {
179                let year_start =
180                    NaiveDate::from_ymd_opt(date.year(), *start_month as u32, *start_day as u32)
181                        .unwrap_or_else(|| {
182                            NaiveDate::from_ymd_opt(date.year(), *start_month as u32, 1)
183                                .expect("valid date components")
184                        });
185                if date >= year_start {
186                    date.year()
187                } else {
188                    date.year() - 1
189                }
190            }
191            FiscalCalendarType::FourFourFive(_) | FiscalCalendarType::ThirteenPeriod(_) => {
192                // APPROXIMATION: Returns the calendar year as a proxy for the fiscal year.
193                //
194                // Both the 4-4-5 and 13-period calendars are week-based: the fiscal year
195                // starts on a specific day of the week (e.g. the last Saturday of January)
196                // anchored by `FourFourFiveConfig::anchor` or a similar configuration.
197                // This means the fiscal year can begin in late December of the *prior*
198                // calendar year, causing dates in the week of Dec 25 – Jan 3 to belong
199                // to a different fiscal year than the calendar year suggests.
200                //
201                // A correct implementation would:
202                //   1. Resolve the anchor date for the current year (e.g. last Saturday ≤ Jan 31).
203                //   2. Compare the input date against that anchor.
204                //   3. Return `date.year() - 1` when the date falls before the anchor.
205                //
206                // Until that logic is implemented, synthetic data generated near
207                // fiscal-year boundaries may have off-by-one period labels.
208                date.year()
209            }
210        }
211    }
212
213    /// Returns the fiscal period number for a given date.
214    pub fn fiscal_period(&self, date: NaiveDate) -> u8 {
215        match &self.calendar_type {
216            FiscalCalendarType::CalendarYear => date.month() as u8,
217            FiscalCalendarType::CustomYearStart {
218                start_month,
219                start_day: _,
220            } => {
221                let month = date.month() as u8;
222                if month >= *start_month {
223                    month - start_month + 1
224                } else {
225                    12 - start_month + month + 1
226                }
227            }
228            FiscalCalendarType::ThirteenPeriod(_) => {
229                // TODO: Simplified — assumes 28 calendar days per period (13 × 28 = 364).
230                // A precise implementation would use the actual week-based boundaries
231                // derived from the configured year-start anchor.
232                let day_of_year = date.ordinal();
233                ((day_of_year - 1) / 28 + 1).min(13) as u8
234            }
235            FiscalCalendarType::FourFourFive(config) => {
236                // APPROXIMATION: Simplified 4-4-5 period calculation.
237                //
238                // This implementation ignores the configured anchor
239                // (`FirstSundayOf` / `LastSaturdayOf`) and assumes the fiscal year starts
240                // on January 1 of the calendar year.  It then maps the date's week-of-year
241                // to a period number using the configured 4-4-5 / 4-5-4 / 5-4-4 pattern.
242                //
243                // Known limitations:
244                //   • Dates near the fiscal-year boundary (late December / early January)
245                //     may be assigned to the wrong period when the true anchor differs from
246                //     January 1.
247                //   • 53-week years (where the 52nd week falls before year-end) are not
248                //     handled; `LeapWeekPlacement` is ignored.
249                //
250                // A precise implementation would resolve the anchor date, build a week-number
251                // → period mapping for the full 52 (or 53) weeks, and look up the input
252                // date's ISO-week number in that mapping.
253                let weeks = config.pattern.weeks_per_period();
254                let week_of_year = (date.ordinal() as u8 - 1) / 7 + 1;
255                let mut cumulative = 0u8;
256                for (quarter, _) in (0..4).enumerate() {
257                    for (period_in_q, &period_weeks) in weeks.iter().enumerate() {
258                        cumulative += period_weeks;
259                        if week_of_year <= cumulative {
260                            return (quarter * 3 + period_in_q + 1) as u8;
261                        }
262                    }
263                }
264                12 // Default to period 12
265            }
266        }
267    }
268}
269
270/// Helper function to get month name.
271fn month_name(month: u8) -> &'static str {
272    match month {
273        1 => "January",
274        2 => "February",
275        3 => "March",
276        4 => "April",
277        5 => "May",
278        6 => "June",
279        7 => "July",
280        8 => "August",
281        9 => "September",
282        10 => "October",
283        11 => "November",
284        12 => "December",
285        _ => "Unknown",
286    }
287}
288
289/// Fiscal period representation.
290#[derive(Debug, Clone, PartialEq, Eq, Hash)]
291pub struct FiscalPeriod {
292    /// Fiscal year.
293    pub year: i32,
294    /// Period number (1-12 for monthly, 1-4 for quarterly).
295    pub period: u8,
296    /// Period start date.
297    pub start_date: NaiveDate,
298    /// Period end date.
299    pub end_date: NaiveDate,
300    /// Period type.
301    pub period_type: FiscalPeriodType,
302    /// Is this the year-end period?
303    pub is_year_end: bool,
304    /// Period status.
305    pub status: PeriodStatus,
306}
307
308impl FiscalPeriod {
309    /// Creates a monthly fiscal period.
310    pub fn monthly(year: i32, month: u8) -> Self {
311        let start_date =
312            NaiveDate::from_ymd_opt(year, month as u32, 1).expect("valid date components");
313        let end_date = if month == 12 {
314            NaiveDate::from_ymd_opt(year + 1, 1, 1)
315                .expect("valid date components")
316                .pred_opt()
317                .expect("valid date components")
318        } else {
319            NaiveDate::from_ymd_opt(year, month as u32 + 1, 1)
320                .expect("valid date components")
321                .pred_opt()
322                .expect("valid date components")
323        };
324
325        Self {
326            year,
327            period: month,
328            start_date,
329            end_date,
330            period_type: FiscalPeriodType::Monthly,
331            is_year_end: month == 12,
332            status: PeriodStatus::Open,
333        }
334    }
335
336    /// Creates a quarterly fiscal period.
337    pub fn quarterly(year: i32, quarter: u8) -> Self {
338        let start_month = (quarter - 1) * 3 + 1;
339        let end_month = quarter * 3;
340
341        let start_date =
342            NaiveDate::from_ymd_opt(year, start_month as u32, 1).expect("valid date components");
343        let end_date = if end_month == 12 {
344            NaiveDate::from_ymd_opt(year + 1, 1, 1)
345                .expect("valid date components")
346                .pred_opt()
347                .expect("valid date components")
348        } else {
349            NaiveDate::from_ymd_opt(year, end_month as u32 + 1, 1)
350                .expect("valid date components")
351                .pred_opt()
352                .expect("valid date components")
353        };
354
355        Self {
356            year,
357            period: quarter,
358            start_date,
359            end_date,
360            period_type: FiscalPeriodType::Quarterly,
361            is_year_end: quarter == 4,
362            status: PeriodStatus::Open,
363        }
364    }
365
366    /// Returns the number of days in the period.
367    pub fn days(&self) -> i64 {
368        (self.end_date - self.start_date).num_days() + 1
369    }
370
371    /// Returns the period key (e.g., "2024-01" for monthly).
372    pub fn key(&self) -> String {
373        format!("{}-{:02}", self.year, self.period)
374    }
375
376    /// Checks if a date falls within this period.
377    pub fn contains(&self, date: NaiveDate) -> bool {
378        date >= self.start_date && date <= self.end_date
379    }
380
381    /// Creates a fiscal period from a calendar and date.
382    ///
383    /// This method determines the correct fiscal period for a given date
384    /// based on the fiscal calendar configuration.
385    pub fn from_calendar(calendar: &FiscalCalendar, date: NaiveDate) -> Self {
386        let fiscal_year = calendar.fiscal_year(date);
387        let period_num = calendar.fiscal_period(date);
388
389        match &calendar.calendar_type {
390            FiscalCalendarType::CalendarYear => Self::monthly(fiscal_year, period_num),
391            FiscalCalendarType::CustomYearStart {
392                start_month,
393                start_day,
394            } => {
395                // Calculate the actual start and end dates for the period
396                let period_start_month = if *start_month + period_num - 1 > 12 {
397                    start_month + period_num - 1 - 12
398                } else {
399                    start_month + period_num - 1
400                };
401                let period_year = if *start_month + period_num - 1 > 12 {
402                    fiscal_year + 1
403                } else {
404                    fiscal_year
405                };
406
407                let start_date = if period_num == 1 {
408                    NaiveDate::from_ymd_opt(fiscal_year, *start_month as u32, *start_day as u32)
409                        .unwrap_or_else(|| {
410                            NaiveDate::from_ymd_opt(fiscal_year, *start_month as u32, 1)
411                                .expect("valid date components")
412                        })
413                } else {
414                    NaiveDate::from_ymd_opt(period_year, period_start_month as u32, 1)
415                        .expect("valid date components")
416                };
417
418                let end_date = if period_num == 12 {
419                    // Last period ends day before fiscal year start
420                    NaiveDate::from_ymd_opt(fiscal_year + 1, *start_month as u32, *start_day as u32)
421                        .unwrap_or_else(|| {
422                            NaiveDate::from_ymd_opt(fiscal_year + 1, *start_month as u32, 1)
423                                .expect("valid date components")
424                        })
425                        .pred_opt()
426                        .expect("valid date components")
427                } else {
428                    let next_month = if period_start_month == 12 {
429                        1
430                    } else {
431                        period_start_month + 1
432                    };
433                    let next_year = if period_start_month == 12 {
434                        period_year + 1
435                    } else {
436                        period_year
437                    };
438                    NaiveDate::from_ymd_opt(next_year, next_month as u32, 1)
439                        .expect("valid date components")
440                        .pred_opt()
441                        .expect("valid date components")
442                };
443
444                Self {
445                    year: fiscal_year,
446                    period: period_num,
447                    start_date,
448                    end_date,
449                    period_type: FiscalPeriodType::Monthly,
450                    is_year_end: period_num == 12,
451                    status: PeriodStatus::Open,
452                }
453            }
454            FiscalCalendarType::FourFourFive(config) => {
455                // 4-4-5 calendar: 12 periods of 4 or 5 weeks each
456                let weeks = config.pattern.weeks_per_period();
457                let quarter = (period_num - 1) / 3;
458                let period_in_quarter = (period_num - 1) % 3;
459                let period_weeks = weeks[period_in_quarter as usize];
460
461                // Calculate start of fiscal year (simplified)
462                let year_start =
463                    NaiveDate::from_ymd_opt(fiscal_year, 1, 1).expect("valid date components");
464
465                // Calculate period start by summing previous period weeks
466                let mut weeks_before = 0u32;
467                for _ in 0..quarter {
468                    for &w in &weeks {
469                        weeks_before += w as u32;
470                    }
471                }
472                for p in 0..period_in_quarter {
473                    weeks_before += weeks[p as usize] as u32;
474                }
475
476                let start_date = year_start + chrono::Duration::weeks(weeks_before as i64);
477                let end_date = start_date + chrono::Duration::weeks(period_weeks as i64)
478                    - chrono::Duration::days(1);
479
480                Self {
481                    year: fiscal_year,
482                    period: period_num,
483                    start_date,
484                    end_date,
485                    period_type: FiscalPeriodType::FourWeek,
486                    is_year_end: period_num == 12,
487                    status: PeriodStatus::Open,
488                }
489            }
490            FiscalCalendarType::ThirteenPeriod(_) => {
491                // 13 periods of 28 days each (4 weeks)
492                let year_start =
493                    NaiveDate::from_ymd_opt(fiscal_year, 1, 1).expect("valid date components");
494                let start_date = year_start + chrono::Duration::days((period_num as i64 - 1) * 28);
495                let end_date = start_date + chrono::Duration::days(27);
496
497                Self {
498                    year: fiscal_year,
499                    period: period_num,
500                    start_date,
501                    end_date,
502                    period_type: FiscalPeriodType::FourWeek,
503                    is_year_end: period_num == 13,
504                    status: PeriodStatus::Open,
505                }
506            }
507        }
508    }
509}
510
511/// Type of fiscal period.
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
513pub enum FiscalPeriodType {
514    /// Monthly period.
515    Monthly,
516    /// Quarterly period.
517    Quarterly,
518    /// Four-week period (used in 4-4-5 and 13-period calendars).
519    FourWeek,
520    /// Special period (13th period, adjustments).
521    Special,
522}
523
524/// Status of a fiscal period.
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
526pub enum PeriodStatus {
527    /// Period is open for posting.
528    Open,
529    /// Soft close - limited posting allowed.
530    SoftClosed,
531    /// Hard close - no posting allowed.
532    Closed,
533    /// Period is locked for audit.
534    Locked,
535}
536
537/// Close task types for period-end processing.
538#[derive(Debug, Clone, PartialEq, Eq, Hash)]
539pub enum CloseTask {
540    /// Run depreciation for fixed assets.
541    RunDepreciation,
542    /// Post inventory revaluation adjustments.
543    PostInventoryRevaluation,
544    /// Reconcile AR subledger to GL.
545    ReconcileArToGl,
546    /// Reconcile AP subledger to GL.
547    ReconcileApToGl,
548    /// Reconcile FA subledger to GL.
549    ReconcileFaToGl,
550    /// Reconcile Inventory to GL.
551    ReconcileInventoryToGl,
552    /// Post accrued expenses.
553    PostAccruedExpenses,
554    /// Post accrued revenue.
555    PostAccruedRevenue,
556    /// Post prepaid expense amortization.
557    PostPrepaidAmortization,
558    /// Allocate corporate overhead.
559    AllocateCorporateOverhead,
560    /// Post intercompany settlements.
561    PostIntercompanySettlements,
562    /// Revalue foreign currency balances.
563    RevalueForeignCurrency,
564    /// Calculate and post tax provision.
565    CalculateTaxProvision,
566    /// Translate foreign subsidiary trial balances.
567    TranslateForeignSubsidiaries,
568    /// Eliminate intercompany balances.
569    EliminateIntercompany,
570    /// Generate trial balance.
571    GenerateTrialBalance,
572    /// Generate financial statements.
573    GenerateFinancialStatements,
574    /// Close income statement accounts (year-end).
575    CloseIncomeStatement,
576    /// Post retained earnings rollforward (year-end).
577    PostRetainedEarningsRollforward,
578    /// Custom task.
579    Custom(String),
580}
581
582impl CloseTask {
583    /// Returns true if this is a year-end only task.
584    pub fn is_year_end_only(&self) -> bool {
585        matches!(
586            self,
587            CloseTask::CloseIncomeStatement | CloseTask::PostRetainedEarningsRollforward
588        )
589    }
590
591    /// Returns the task name.
592    pub fn name(&self) -> &str {
593        match self {
594            CloseTask::RunDepreciation => "Run Depreciation",
595            CloseTask::PostInventoryRevaluation => "Post Inventory Revaluation",
596            CloseTask::ReconcileArToGl => "Reconcile AR to GL",
597            CloseTask::ReconcileApToGl => "Reconcile AP to GL",
598            CloseTask::ReconcileFaToGl => "Reconcile FA to GL",
599            CloseTask::ReconcileInventoryToGl => "Reconcile Inventory to GL",
600            CloseTask::PostAccruedExpenses => "Post Accrued Expenses",
601            CloseTask::PostAccruedRevenue => "Post Accrued Revenue",
602            CloseTask::PostPrepaidAmortization => "Post Prepaid Amortization",
603            CloseTask::AllocateCorporateOverhead => "Allocate Corporate Overhead",
604            CloseTask::PostIntercompanySettlements => "Post IC Settlements",
605            CloseTask::RevalueForeignCurrency => "Revalue Foreign Currency",
606            CloseTask::CalculateTaxProvision => "Calculate Tax Provision",
607            CloseTask::TranslateForeignSubsidiaries => "Translate Foreign Subs",
608            CloseTask::EliminateIntercompany => "Eliminate Intercompany",
609            CloseTask::GenerateTrialBalance => "Generate Trial Balance",
610            CloseTask::GenerateFinancialStatements => "Generate Financials",
611            CloseTask::CloseIncomeStatement => "Close Income Statement",
612            CloseTask::PostRetainedEarningsRollforward => "Post RE Rollforward",
613            CloseTask::Custom(name) => name,
614        }
615    }
616}
617
618/// Status of a close task execution.
619#[derive(Debug, Clone, PartialEq, Eq)]
620pub enum CloseTaskStatus {
621    /// Not started.
622    Pending,
623    /// In progress.
624    InProgress,
625    /// Completed successfully.
626    Completed,
627    /// Completed with warnings.
628    CompletedWithWarnings(Vec<String>),
629    /// Failed.
630    Failed(String),
631    /// Skipped.
632    Skipped(String),
633}
634
635/// Result of executing a close task.
636#[derive(Debug, Clone)]
637pub struct CloseTaskResult {
638    /// Task that was executed.
639    pub task: CloseTask,
640    /// Company code.
641    pub company_code: String,
642    /// Fiscal period.
643    pub fiscal_period: FiscalPeriod,
644    /// Status.
645    pub status: CloseTaskStatus,
646    /// Start time.
647    pub started_at: Option<NaiveDate>,
648    /// End time.
649    pub completed_at: Option<NaiveDate>,
650    /// Journal entries created.
651    pub journal_entries_created: u32,
652    /// Total amount posted.
653    pub total_amount: Decimal,
654    /// Execution notes.
655    pub notes: Vec<String>,
656}
657
658impl CloseTaskResult {
659    /// Creates a new task result.
660    pub fn new(task: CloseTask, company_code: String, fiscal_period: FiscalPeriod) -> Self {
661        Self {
662            task,
663            company_code,
664            fiscal_period,
665            status: CloseTaskStatus::Pending,
666            started_at: None,
667            completed_at: None,
668            journal_entries_created: 0,
669            total_amount: Decimal::ZERO,
670            notes: Vec::new(),
671        }
672    }
673
674    /// Returns true if the task completed successfully.
675    pub fn is_success(&self) -> bool {
676        matches!(
677            self.status,
678            CloseTaskStatus::Completed | CloseTaskStatus::CompletedWithWarnings(_)
679        )
680    }
681}
682
683/// Accrual definition for recurring period-end entries.
684#[derive(Debug, Clone)]
685pub struct AccrualDefinition {
686    /// Accrual ID.
687    pub accrual_id: String,
688    /// Company code.
689    pub company_code: String,
690    /// Description.
691    pub description: String,
692    /// Accrual type.
693    pub accrual_type: AccrualType,
694    /// Expense/Revenue account to debit/credit.
695    pub expense_revenue_account: String,
696    /// Accrual liability/asset account.
697    pub accrual_account: String,
698    /// Calculation method.
699    pub calculation_method: AccrualCalculationMethod,
700    /// Fixed amount (if applicable).
701    pub fixed_amount: Option<Decimal>,
702    /// Percentage rate (if applicable).
703    pub percentage_rate: Option<Decimal>,
704    /// Base account for percentage calculation.
705    pub base_account: Option<String>,
706    /// Frequency.
707    pub frequency: AccrualFrequency,
708    /// Auto-reverse on first day of next period.
709    pub auto_reverse: bool,
710    /// Cost center.
711    pub cost_center: Option<String>,
712    /// Active flag.
713    pub is_active: bool,
714    /// Start date.
715    pub effective_from: NaiveDate,
716    /// End date (if defined).
717    pub effective_to: Option<NaiveDate>,
718}
719
720impl AccrualDefinition {
721    /// Creates a new accrual definition.
722    pub fn new(
723        accrual_id: String,
724        company_code: String,
725        description: String,
726        accrual_type: AccrualType,
727        expense_revenue_account: String,
728        accrual_account: String,
729    ) -> Self {
730        Self {
731            accrual_id,
732            company_code,
733            description,
734            accrual_type,
735            expense_revenue_account,
736            accrual_account,
737            calculation_method: AccrualCalculationMethod::FixedAmount,
738            fixed_amount: None,
739            percentage_rate: None,
740            base_account: None,
741            frequency: AccrualFrequency::Monthly,
742            auto_reverse: true,
743            cost_center: None,
744            is_active: true,
745            effective_from: NaiveDate::from_ymd_opt(2020, 1, 1).expect("valid date components"),
746            effective_to: None,
747        }
748    }
749
750    /// Sets the fixed amount.
751    pub fn with_fixed_amount(mut self, amount: Decimal) -> Self {
752        self.calculation_method = AccrualCalculationMethod::FixedAmount;
753        self.fixed_amount = Some(amount);
754        self
755    }
756
757    /// Sets percentage-based calculation.
758    pub fn with_percentage(mut self, rate: Decimal, base_account: &str) -> Self {
759        self.calculation_method = AccrualCalculationMethod::PercentageOfBase;
760        self.percentage_rate = Some(rate);
761        self.base_account = Some(base_account.to_string());
762        self
763    }
764
765    /// Checks if the accrual is effective for a given date.
766    pub fn is_effective_on(&self, date: NaiveDate) -> bool {
767        if !self.is_active {
768            return false;
769        }
770        if date < self.effective_from {
771            return false;
772        }
773        if let Some(end) = self.effective_to {
774            if date > end {
775                return false;
776            }
777        }
778        true
779    }
780}
781
782/// Type of accrual.
783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub enum AccrualType {
785    /// Accrued expense (debit expense, credit liability).
786    AccruedExpense,
787    /// Accrued revenue (debit asset, credit revenue).
788    AccruedRevenue,
789    /// Prepaid expense (debit expense, credit asset).
790    PrepaidExpense,
791    /// Deferred revenue (debit liability, credit revenue).
792    DeferredRevenue,
793}
794
795/// Calculation method for accruals.
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797pub enum AccrualCalculationMethod {
798    /// Fixed amount each period.
799    FixedAmount,
800    /// Percentage of a base account balance.
801    PercentageOfBase,
802    /// Days-based proration.
803    DaysBased,
804    /// Calculated externally (manual entry).
805    Manual,
806}
807
808/// Frequency for accrual posting.
809#[derive(Debug, Clone, Copy, PartialEq, Eq)]
810pub enum AccrualFrequency {
811    /// Every month.
812    Monthly,
813    /// Every quarter.
814    Quarterly,
815    /// Every year.
816    Annually,
817}
818
819/// Corporate overhead allocation definition.
820#[derive(Debug, Clone)]
821pub struct OverheadAllocation {
822    /// Allocation ID.
823    pub allocation_id: String,
824    /// Source company code (corporate).
825    pub source_company: String,
826    /// Source cost center.
827    pub source_cost_center: String,
828    /// Source account.
829    pub source_account: String,
830    /// Allocation basis.
831    pub allocation_basis: AllocationBasis,
832    /// Target allocations.
833    pub targets: Vec<AllocationTarget>,
834    /// Description.
835    pub description: String,
836    /// Active flag.
837    pub is_active: bool,
838}
839
840/// Basis for overhead allocation.
841#[derive(Debug, Clone, PartialEq, Eq)]
842pub enum AllocationBasis {
843    /// Based on revenue.
844    Revenue,
845    /// Based on headcount.
846    Headcount,
847    /// Based on direct costs.
848    DirectCosts,
849    /// Based on square footage.
850    SquareFootage,
851    /// Fixed percentages.
852    FixedPercentage,
853    /// Custom formula.
854    Custom(String),
855}
856
857/// Target for overhead allocation.
858#[derive(Debug, Clone)]
859pub struct AllocationTarget {
860    /// Target company code.
861    pub company_code: String,
862    /// Target cost center.
863    pub cost_center: String,
864    /// Target account.
865    pub account: String,
866    /// Allocation percentage (for fixed percentage basis).
867    pub percentage: Option<Decimal>,
868    /// Allocation driver value (for calculated basis).
869    pub driver_value: Option<Decimal>,
870}
871
872/// Period close schedule defining the order of tasks.
873#[derive(Debug, Clone)]
874pub struct CloseSchedule {
875    /// Schedule ID.
876    pub schedule_id: String,
877    /// Company code (or "ALL" for all companies).
878    pub company_code: String,
879    /// Period type this schedule applies to.
880    pub period_type: FiscalPeriodType,
881    /// Ordered list of tasks.
882    pub tasks: Vec<ScheduledCloseTask>,
883    /// Whether this is for year-end.
884    pub is_year_end: bool,
885}
886
887impl CloseSchedule {
888    /// Creates a standard monthly close schedule.
889    pub fn standard_monthly(company_code: &str) -> Self {
890        Self {
891            schedule_id: format!("MONTHLY-{company_code}"),
892            company_code: company_code.to_string(),
893            period_type: FiscalPeriodType::Monthly,
894            tasks: vec![
895                ScheduledCloseTask::new(CloseTask::RunDepreciation, 1),
896                ScheduledCloseTask::new(CloseTask::PostInventoryRevaluation, 2),
897                ScheduledCloseTask::new(CloseTask::PostAccruedExpenses, 3),
898                ScheduledCloseTask::new(CloseTask::PostAccruedRevenue, 4),
899                ScheduledCloseTask::new(CloseTask::PostPrepaidAmortization, 5),
900                ScheduledCloseTask::new(CloseTask::RevalueForeignCurrency, 6),
901                ScheduledCloseTask::new(CloseTask::ReconcileArToGl, 7),
902                ScheduledCloseTask::new(CloseTask::ReconcileApToGl, 8),
903                ScheduledCloseTask::new(CloseTask::ReconcileFaToGl, 9),
904                ScheduledCloseTask::new(CloseTask::ReconcileInventoryToGl, 10),
905                ScheduledCloseTask::new(CloseTask::PostIntercompanySettlements, 11),
906                ScheduledCloseTask::new(CloseTask::AllocateCorporateOverhead, 12),
907                ScheduledCloseTask::new(CloseTask::TranslateForeignSubsidiaries, 13),
908                ScheduledCloseTask::new(CloseTask::EliminateIntercompany, 14),
909                ScheduledCloseTask::new(CloseTask::GenerateTrialBalance, 15),
910            ],
911            is_year_end: false,
912        }
913    }
914
915    /// Creates a year-end close schedule.
916    pub fn year_end(company_code: &str) -> Self {
917        let mut schedule = Self::standard_monthly(company_code);
918        schedule.schedule_id = format!("YEAREND-{company_code}");
919        schedule.is_year_end = true;
920
921        // Add year-end specific tasks
922        let next_seq = schedule.tasks.len() as u32 + 1;
923        schedule.tasks.push(ScheduledCloseTask::new(
924            CloseTask::CalculateTaxProvision,
925            next_seq,
926        ));
927        schedule.tasks.push(ScheduledCloseTask::new(
928            CloseTask::CloseIncomeStatement,
929            next_seq + 1,
930        ));
931        schedule.tasks.push(ScheduledCloseTask::new(
932            CloseTask::PostRetainedEarningsRollforward,
933            next_seq + 2,
934        ));
935        schedule.tasks.push(ScheduledCloseTask::new(
936            CloseTask::GenerateFinancialStatements,
937            next_seq + 3,
938        ));
939
940        schedule
941    }
942}
943
944/// A scheduled close task with sequence and dependencies.
945#[derive(Debug, Clone)]
946pub struct ScheduledCloseTask {
947    /// The task to execute.
948    pub task: CloseTask,
949    /// Sequence number (execution order).
950    pub sequence: u32,
951    /// Tasks that must complete before this one.
952    pub depends_on: Vec<CloseTask>,
953    /// Is this task mandatory?
954    pub is_mandatory: bool,
955    /// Can this task run in parallel with others at same sequence?
956    pub can_parallelize: bool,
957}
958
959impl ScheduledCloseTask {
960    /// Creates a new scheduled task.
961    pub fn new(task: CloseTask, sequence: u32) -> Self {
962        Self {
963            task,
964            sequence,
965            depends_on: Vec::new(),
966            is_mandatory: true,
967            can_parallelize: false,
968        }
969    }
970
971    /// Adds a dependency.
972    pub fn depends_on(mut self, task: CloseTask) -> Self {
973        self.depends_on.push(task);
974        self
975    }
976
977    /// Marks as optional.
978    pub fn optional(mut self) -> Self {
979        self.is_mandatory = false;
980        self
981    }
982
983    /// Allows parallel execution.
984    pub fn parallelizable(mut self) -> Self {
985        self.can_parallelize = true;
986        self
987    }
988}
989
990/// Year-end closing entry specification.
991#[derive(Debug, Clone)]
992pub struct YearEndClosingSpec {
993    /// Company code.
994    pub company_code: String,
995    /// Fiscal year being closed.
996    pub fiscal_year: i32,
997    /// Revenue accounts to close.
998    pub revenue_accounts: Vec<String>,
999    /// Expense accounts to close.
1000    pub expense_accounts: Vec<String>,
1001    /// Income summary account (temporary).
1002    pub income_summary_account: String,
1003    /// Retained earnings account.
1004    pub retained_earnings_account: String,
1005    /// Dividend account (if applicable).
1006    pub dividend_account: Option<String>,
1007}
1008
1009impl Default for YearEndClosingSpec {
1010    fn default() -> Self {
1011        Self {
1012            company_code: String::new(),
1013            fiscal_year: 0,
1014            revenue_accounts: vec!["4".to_string()], // All accounts starting with 4
1015            expense_accounts: vec!["5".to_string(), "6".to_string()], // Accounts starting with 5, 6
1016            income_summary_account: "3500".to_string(),
1017            retained_earnings_account: "3300".to_string(),
1018            dividend_account: Some("3400".to_string()),
1019        }
1020    }
1021}
1022
1023/// Tax provision calculation inputs.
1024#[derive(Debug, Clone)]
1025pub struct TaxProvisionInput {
1026    /// Company code.
1027    pub company_code: String,
1028    /// Fiscal year.
1029    pub fiscal_year: i32,
1030    /// Pre-tax book income.
1031    pub pretax_income: Decimal,
1032    /// Permanent differences (add back).
1033    pub permanent_differences: Vec<TaxAdjustment>,
1034    /// Temporary differences (timing).
1035    pub temporary_differences: Vec<TaxAdjustment>,
1036    /// Statutory tax rate.
1037    pub statutory_rate: Decimal,
1038    /// Tax credits available.
1039    pub tax_credits: Decimal,
1040    /// Prior year over/under provision.
1041    pub prior_year_adjustment: Decimal,
1042}
1043
1044/// Tax adjustment item.
1045#[derive(Debug, Clone)]
1046pub struct TaxAdjustment {
1047    /// Description.
1048    pub description: String,
1049    /// Amount.
1050    pub amount: Decimal,
1051    /// Is this a deduction (negative) or addition (positive)?
1052    pub is_addition: bool,
1053}
1054
1055/// Tax provision result.
1056#[derive(Debug, Clone)]
1057pub struct TaxProvisionResult {
1058    /// Company code.
1059    pub company_code: String,
1060    /// Fiscal year.
1061    pub fiscal_year: i32,
1062    /// Pre-tax book income.
1063    pub pretax_income: Decimal,
1064    /// Total permanent differences.
1065    pub permanent_differences: Decimal,
1066    /// Taxable income.
1067    pub taxable_income: Decimal,
1068    /// Current tax expense.
1069    pub current_tax_expense: Decimal,
1070    /// Deferred tax expense (benefit).
1071    pub deferred_tax_expense: Decimal,
1072    /// Total tax expense.
1073    pub total_tax_expense: Decimal,
1074    /// Effective tax rate.
1075    pub effective_rate: Decimal,
1076}
1077
1078impl TaxProvisionResult {
1079    /// Calculates the tax provision from inputs.
1080    pub fn calculate(input: &TaxProvisionInput) -> Self {
1081        let permanent_diff: Decimal = input
1082            .permanent_differences
1083            .iter()
1084            .map(|d| if d.is_addition { d.amount } else { -d.amount })
1085            .sum();
1086
1087        let temporary_diff: Decimal = input
1088            .temporary_differences
1089            .iter()
1090            .map(|d| if d.is_addition { d.amount } else { -d.amount })
1091            .sum();
1092
1093        let taxable_income = input.pretax_income + permanent_diff;
1094        let current_tax = (taxable_income * input.statutory_rate / dec!(100)).round_dp(2);
1095        let deferred_tax = (temporary_diff * input.statutory_rate / dec!(100)).round_dp(2);
1096
1097        let total_tax =
1098            current_tax + deferred_tax - input.tax_credits + input.prior_year_adjustment;
1099
1100        let effective_rate = if input.pretax_income != Decimal::ZERO {
1101            (total_tax / input.pretax_income * dec!(100)).round_dp(2)
1102        } else {
1103            Decimal::ZERO
1104        };
1105
1106        Self {
1107            company_code: input.company_code.clone(),
1108            fiscal_year: input.fiscal_year,
1109            pretax_income: input.pretax_income,
1110            permanent_differences: permanent_diff,
1111            taxable_income,
1112            current_tax_expense: current_tax,
1113            deferred_tax_expense: deferred_tax,
1114            total_tax_expense: total_tax,
1115            effective_rate,
1116        }
1117    }
1118}
1119
1120/// Period close run status.
1121#[derive(Debug, Clone)]
1122pub struct PeriodCloseRun {
1123    /// Run ID.
1124    pub run_id: String,
1125    /// Company code.
1126    pub company_code: String,
1127    /// Fiscal period.
1128    pub fiscal_period: FiscalPeriod,
1129    /// Status.
1130    pub status: PeriodCloseStatus,
1131    /// Task results.
1132    pub task_results: Vec<CloseTaskResult>,
1133    /// Started at.
1134    pub started_at: Option<NaiveDate>,
1135    /// Completed at.
1136    pub completed_at: Option<NaiveDate>,
1137    /// Total journal entries created.
1138    pub total_journal_entries: u32,
1139    /// Errors encountered.
1140    pub errors: Vec<String>,
1141}
1142
1143/// Status of a period close run.
1144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1145pub enum PeriodCloseStatus {
1146    /// Not started.
1147    NotStarted,
1148    /// In progress.
1149    InProgress,
1150    /// Completed successfully.
1151    Completed,
1152    /// Completed with errors.
1153    CompletedWithErrors,
1154    /// Failed.
1155    Failed,
1156}
1157
1158impl PeriodCloseRun {
1159    /// Creates a new period close run.
1160    pub fn new(run_id: String, company_code: String, fiscal_period: FiscalPeriod) -> Self {
1161        Self {
1162            run_id,
1163            company_code,
1164            fiscal_period,
1165            status: PeriodCloseStatus::NotStarted,
1166            task_results: Vec::new(),
1167            started_at: None,
1168            completed_at: None,
1169            total_journal_entries: 0,
1170            errors: Vec::new(),
1171        }
1172    }
1173
1174    /// Returns true if all tasks completed successfully.
1175    pub fn is_success(&self) -> bool {
1176        self.status == PeriodCloseStatus::Completed
1177    }
1178
1179    /// Returns the number of failed tasks.
1180    pub fn failed_task_count(&self) -> usize {
1181        self.task_results
1182            .iter()
1183            .filter(|r| matches!(r.status, CloseTaskStatus::Failed(_)))
1184            .count()
1185    }
1186}
1187
1188#[cfg(test)]
1189#[allow(clippy::unwrap_used)]
1190mod tests {
1191    use super::*;
1192
1193    #[test]
1194    fn test_fiscal_period_monthly() {
1195        let period = FiscalPeriod::monthly(2024, 1);
1196        assert_eq!(
1197            period.start_date,
1198            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
1199        );
1200        assert_eq!(
1201            period.end_date,
1202            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()
1203        );
1204        assert_eq!(period.days(), 31);
1205        assert!(!period.is_year_end);
1206
1207        let dec_period = FiscalPeriod::monthly(2024, 12);
1208        assert!(dec_period.is_year_end);
1209    }
1210
1211    #[test]
1212    fn test_fiscal_period_quarterly() {
1213        let q1 = FiscalPeriod::quarterly(2024, 1);
1214        assert_eq!(q1.start_date, NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
1215        assert_eq!(q1.end_date, NaiveDate::from_ymd_opt(2024, 3, 31).unwrap());
1216
1217        let q4 = FiscalPeriod::quarterly(2024, 4);
1218        assert!(q4.is_year_end);
1219    }
1220
1221    #[test]
1222    fn test_close_schedule() {
1223        let schedule = CloseSchedule::standard_monthly("1000");
1224        assert!(!schedule.is_year_end);
1225        assert!(!schedule.tasks.is_empty());
1226
1227        let year_end = CloseSchedule::year_end("1000");
1228        assert!(year_end.is_year_end);
1229        assert!(year_end.tasks.len() > schedule.tasks.len());
1230    }
1231
1232    #[test]
1233    fn test_tax_provision() {
1234        let input = TaxProvisionInput {
1235            company_code: "1000".to_string(),
1236            fiscal_year: 2024,
1237            pretax_income: dec!(1000000),
1238            permanent_differences: vec![TaxAdjustment {
1239                description: "Meals & Entertainment".to_string(),
1240                amount: dec!(10000),
1241                is_addition: true,
1242            }],
1243            temporary_differences: vec![TaxAdjustment {
1244                description: "Depreciation Timing".to_string(),
1245                amount: dec!(50000),
1246                is_addition: false,
1247            }],
1248            statutory_rate: dec!(21),
1249            tax_credits: dec!(5000),
1250            prior_year_adjustment: Decimal::ZERO,
1251        };
1252
1253        let result = TaxProvisionResult::calculate(&input);
1254        assert_eq!(result.taxable_income, dec!(1010000)); // 1M + 10K permanent
1255        assert!(result.current_tax_expense > Decimal::ZERO);
1256    }
1257}