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}