Skip to main content

stateset_core/models/
close_month.rs

1//! Month-end close orchestration report types
2//!
3//! The "close the month" operation runs, in order: scheduled fixed-asset
4//! depreciation, revenue recognition through period end, FX revaluation as of
5//! period end, and the period close itself (closing entries + close period).
6//! These types describe the options controlling the run and the per-step
7//! report it returns. Per-item failures (e.g. a single asset that cannot post)
8//! are collected as warnings and never abort the close.
9
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use std::fmt;
13use uuid::Uuid;
14
15use super::general_ledger::{JournalEntry, PeriodStatus};
16
17/// Options controlling a month-end close run.
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct CloseMonthOptions {
20    /// Compute what WOULD happen (counts and amounts per step) without
21    /// writing anything.
22    pub dry_run: bool,
23    /// Skip posting scheduled fixed-asset depreciation.
24    pub skip_depreciation: bool,
25    /// Skip recognizing deferred revenue through period end.
26    pub skip_revenue_recognition: bool,
27    /// Skip FX revaluation of foreign-currency accounts.
28    pub skip_fx_revaluation: bool,
29    /// Skip the final period close (closing entries + close period).
30    pub skip_period_close: bool,
31    /// Actor recorded as the closer. Defaults to `system`.
32    pub closed_by: Option<String>,
33}
34
35/// Outcome of one step in a month-end close run.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38#[non_exhaustive]
39pub enum CloseMonthStepStatus {
40    /// The step ran and wrote its entries.
41    Executed,
42    /// The step was skipped (by flag, missing capability, or nothing to do
43    /// that could be attempted, e.g. no FX account configured).
44    Skipped,
45    /// Candidates were computed but nothing was written (dry run).
46    DryRun,
47}
48
49impl fmt::Display for CloseMonthStepStatus {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::Executed => write!(f, "executed"),
53            Self::Skipped => write!(f, "skipped"),
54            Self::DryRun => write!(f, "dry_run"),
55        }
56    }
57}
58
59/// Per-step detail of a month-end close run.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CloseMonthStepReport {
62    /// Whether the step executed, was skipped, or ran in dry-run mode.
63    pub status: CloseMonthStepStatus,
64    /// Number of entries posted (or that would be posted in a dry run):
65    /// depreciation schedule entries, revenue schedule entries, revaluation
66    /// journal entries, or closing entries.
67    pub entry_count: u64,
68    /// Total amount across those entries (depreciation posted, revenue
69    /// recognized, net unrealized FX gain/loss, or closing entry debits).
70    pub total_amount: Decimal,
71    /// Per-item failures and notes that did not abort the close.
72    pub warnings: Vec<String>,
73}
74
75impl CloseMonthStepReport {
76    /// A step that was skipped, optionally with a note explaining why.
77    #[must_use]
78    pub fn skipped(warning: Option<String>) -> Self {
79        Self {
80            status: CloseMonthStepStatus::Skipped,
81            entry_count: 0,
82            total_amount: Decimal::ZERO,
83            warnings: warning.into_iter().collect(),
84        }
85    }
86}
87
88/// Report returned by the month-end close orchestration.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct CloseMonthReport {
91    /// Period that was closed (or evaluated in dry-run mode).
92    pub period_id: Uuid,
93    /// Denormalized period name.
94    pub period_name: String,
95    /// Whether this was a dry run (nothing was written).
96    pub dry_run: bool,
97    /// Step 1: scheduled fixed-asset depreciation due through period end.
98    pub depreciation: CloseMonthStepReport,
99    /// Step 2: deferred revenue recognized through period end.
100    pub revenue_recognition: CloseMonthStepReport,
101    /// Step 3: FX revaluation of foreign-currency accounts as of period end.
102    pub fx_revaluation: CloseMonthStepReport,
103    /// Step 4: closing entries + close period.
104    pub period_close: CloseMonthStepReport,
105    /// The posted closing entry; `None` for dry runs, skipped closes, or a
106    /// period with nothing to close.
107    pub closing_entry: Option<JournalEntry>,
108    /// Period status after the run (`Closed` after a real close).
109    pub period_status: PeriodStatus,
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn step_status_serializes_snake_case() {
118        assert_eq!(
119            serde_json::to_string(&CloseMonthStepStatus::DryRun).expect("serialize"),
120            "\"dry_run\""
121        );
122        assert_eq!(CloseMonthStepStatus::Executed.to_string(), "executed");
123    }
124
125    #[test]
126    fn skipped_helper_collects_warning() {
127        let step = CloseMonthStepReport::skipped(Some("why".into()));
128        assert_eq!(step.status, CloseMonthStepStatus::Skipped);
129        assert_eq!(step.entry_count, 0);
130        assert!(step.total_amount.is_zero());
131        assert_eq!(step.warnings, vec!["why".to_string()]);
132        assert!(CloseMonthStepReport::skipped(None).warnings.is_empty());
133    }
134
135    #[test]
136    fn options_default_is_full_wet_run() {
137        let options = CloseMonthOptions::default();
138        assert!(!options.dry_run);
139        assert!(!options.skip_depreciation);
140        assert!(!options.skip_revenue_recognition);
141        assert!(!options.skip_fx_revaluation);
142        assert!(!options.skip_period_close);
143        assert!(options.closed_by.is_none());
144    }
145}