Skip to main content

datasynth_generators/subledger/
depreciation_run_generator.rs

1//! Subledger Depreciation Schedule Generator.
2//!
3//! Produces periodic `DepreciationRun` records by driving the existing
4//! `FAGenerator::run_depreciation()` over the set of `FixedAssetRecord`
5//! entries in the subledger snapshot.  One run is emitted per fiscal period
6//! for each company that has active assets.
7//!
8//! # Distinction from `period_close::DepreciationRunGenerator`
9//!
10//! The period-close variant (`DepreciationRunGenerator` in `period_close/depreciation.rs`)
11//! is driven by `FiscalPeriod` objects and mutates the asset list.  This
12//! subledger variant takes an immutable slice of `FixedAssetRecord` and
13//! produces a complete multi-period schedule in a single call, for use when
14//! populating the `SubledgerSnapshot`.
15
16use chrono::{Datelike, NaiveDate};
17use rand::SeedableRng;
18
19use datasynth_core::models::subledger::fa::{DepreciationRun, FixedAssetRecord};
20
21use crate::FAGenerator;
22use crate::FAGeneratorConfig;
23
24/// Configuration for the subledger depreciation schedule generator.
25#[derive(Debug, Clone)]
26pub struct FaDepreciationScheduleConfig {
27    /// Fiscal year to generate runs for.
28    pub fiscal_year: i32,
29    /// First fiscal period (1 = January if calendar year, or first month of FY).
30    pub start_period: u32,
31    /// Last fiscal period (inclusive).
32    pub end_period: u32,
33    /// Seed offset added to the base seed so runs are deterministic but
34    /// independent of other FA generator uses.
35    pub seed_offset: u64,
36}
37
38impl Default for FaDepreciationScheduleConfig {
39    fn default() -> Self {
40        let year = chrono::Utc::now().date_naive().year();
41        Self {
42            fiscal_year: year,
43            start_period: 1,
44            end_period: 12,
45            seed_offset: 800,
46        }
47    }
48}
49
50/// Generator that creates one `DepreciationRun` per fiscal period from a
51/// subledger FA snapshot.
52pub struct FaDepreciationScheduleGenerator {
53    config: FaDepreciationScheduleConfig,
54    seed: u64,
55}
56
57impl FaDepreciationScheduleGenerator {
58    /// Creates a new generator with the given base seed.
59    pub fn new(config: FaDepreciationScheduleConfig, seed: u64) -> Self {
60        Self { config, seed }
61    }
62
63    /// Generates depreciation runs for all periods between `start_period` and
64    /// `end_period` (inclusive) for the given company and asset set.
65    ///
66    /// Returns a `Vec<DepreciationRun>` — one entry per period that contains at
67    /// least one active, non-fully-depreciated asset.
68    pub fn generate(
69        &self,
70        company_code: &str,
71        fa_records: &[FixedAssetRecord],
72    ) -> Vec<DepreciationRun> {
73        if fa_records.is_empty() {
74            return Vec::new();
75        }
76
77        let mut fa_gen = FAGenerator::new(
78            FAGeneratorConfig::default(),
79            rand_chacha::ChaCha8Rng::seed_from_u64(self.seed + self.config.seed_offset),
80        );
81
82        let asset_refs: Vec<&FixedAssetRecord> = fa_records.iter().collect();
83
84        let mut runs = Vec::new();
85
86        for period in self.config.start_period..=self.config.end_period {
87            // Build a period date: last day of the month for this period in the FY.
88            // Periods are assumed to be calendar months (period 1 = Jan, 12 = Dec).
89            let (year, month) = if period > 12 {
90                // Handle non-standard period numbering gracefully.
91                (self.config.fiscal_year, 12u32)
92            } else {
93                (self.config.fiscal_year, period)
94            };
95
96            let period_date = last_day_of_month(year, month);
97
98            let (run, _jes) = fa_gen.run_depreciation(
99                company_code,
100                &asset_refs,
101                period_date,
102                self.config.fiscal_year,
103                period,
104            );
105
106            if run.asset_count > 0 {
107                runs.push(run);
108            }
109        }
110
111        runs
112    }
113}
114
115/// Returns the last day of the given year/month.
116fn last_day_of_month(year: i32, month: u32) -> NaiveDate {
117    let next_month = if month == 12 { 1 } else { month + 1 };
118    let next_year = if month == 12 { year + 1 } else { year };
119    NaiveDate::from_ymd_opt(next_year, next_month, 1)
120        .expect("valid next-month date")
121        .pred_opt()
122        .expect("valid last-day date")
123}
124
125#[cfg(test)]
126#[allow(clippy::unwrap_used)]
127mod tests {
128    use super::*;
129    use datasynth_core::models::subledger::fa::{
130        AssetClass, DepreciationArea, DepreciationAreaType, DepreciationMethod,
131    };
132    use rust_decimal::Decimal;
133    use rust_decimal_macros::dec;
134
135    fn make_asset(id: &str, company: &str, acquisition_cost: Decimal) -> FixedAssetRecord {
136        let mut asset = FixedAssetRecord::new(
137            id.to_string(),
138            company.to_string(),
139            AssetClass::MachineryEquipment,
140            format!("Machine {id}"),
141            NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
142            acquisition_cost,
143            "USD".to_string(),
144        );
145        let area = DepreciationArea::new(
146            DepreciationAreaType::Book,
147            DepreciationMethod::StraightLine,
148            60, // 5 years = 60 months
149            acquisition_cost,
150        );
151        asset.add_depreciation_area(area);
152        asset
153    }
154
155    #[test]
156    fn test_straight_line_monthly_amount() {
157        // acquisition_cost = 60_000, useful_life = 60 months, salvage = 0
158        // monthly depreciation = 60_000 / 60 = 1_000
159        let asset = make_asset("A001", "1000", dec!(60_000));
160        let cfg = FaDepreciationScheduleConfig {
161            fiscal_year: 2024,
162            start_period: 1,
163            end_period: 1,
164            seed_offset: 0,
165        };
166        let gen = FaDepreciationScheduleGenerator::new(cfg, 42);
167        let runs = gen.generate("1000", &[asset]);
168
169        assert_eq!(runs.len(), 1, "Expected exactly one run for period 1");
170        let run = &runs[0];
171        assert_eq!(run.fiscal_period, 1);
172        assert_eq!(run.asset_count, 1);
173        assert_eq!(
174            run.total_depreciation,
175            dec!(1_000),
176            "Straight-line monthly depreciation should be 1_000"
177        );
178    }
179
180    #[test]
181    fn test_accumulated_increases_each_period() {
182        // Two periods for the same asset — verify both runs are generated.
183        let asset = make_asset("A002", "1000", dec!(120_000));
184        let cfg = FaDepreciationScheduleConfig {
185            fiscal_year: 2024,
186            start_period: 1,
187            end_period: 2,
188            seed_offset: 1,
189        };
190        let gen = FaDepreciationScheduleGenerator::new(cfg, 99);
191        let runs = gen.generate("1000", &[asset]);
192
193        assert_eq!(runs.len(), 2, "Expected two runs (one per period)");
194        // Both runs should have positive depreciation.
195        for run in &runs {
196            assert!(
197                run.total_depreciation > Decimal::ZERO,
198                "Depreciation should be positive"
199            );
200        }
201        // Straight-line gives equal per-period amounts.
202        assert_eq!(
203            runs[0].total_depreciation, runs[1].total_depreciation,
204            "Straight-line gives equal depreciation each period"
205        );
206    }
207
208    #[test]
209    fn test_empty_fa_records_returns_empty() {
210        let cfg = FaDepreciationScheduleConfig::default();
211        let gen = FaDepreciationScheduleGenerator::new(cfg, 0);
212        let runs = gen.generate("1000", &[]);
213        assert!(runs.is_empty());
214    }
215
216    #[test]
217    fn test_twelve_periods_generated() {
218        let asset = make_asset("A003", "1000", dec!(36_000));
219        let cfg = FaDepreciationScheduleConfig {
220            fiscal_year: 2024,
221            start_period: 1,
222            end_period: 12,
223            seed_offset: 2,
224        };
225        let gen = FaDepreciationScheduleGenerator::new(cfg, 7);
226        let runs = gen.generate("1000", &[asset]);
227        assert_eq!(runs.len(), 12, "Should produce 12 monthly runs");
228    }
229}