Skip to main content

datasynth_generators/subledger/
fa_generator.rs

1//! Fixed Assets (FA) generator.
2
3use chrono::NaiveDate;
4use datasynth_core::utils::seeded_rng;
5use rand::Rng;
6use rand_chacha::ChaCha8Rng;
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9
10use tracing::debug;
11
12use datasynth_core::models::subledger::fa::{
13    AssetClass, AssetDisposal, AssetStatus, DepreciationArea, DepreciationAreaType,
14    DepreciationEntry, DepreciationMethod, DepreciationRun, DisposalReason, DisposalType,
15    FixedAssetRecord,
16};
17use datasynth_core::models::{JournalEntry, JournalEntryLine};
18
19/// Configuration for FA generation.
20#[derive(Debug, Clone)]
21pub struct FAGeneratorConfig {
22    /// Default depreciation method.
23    pub default_depreciation_method: DepreciationMethod,
24    /// Default useful life in months.
25    pub default_useful_life_months: u32,
26    /// Salvage value percentage.
27    pub salvage_value_percent: Decimal,
28    /// Average acquisition cost.
29    pub avg_acquisition_cost: Decimal,
30    /// Cost variation factor.
31    pub cost_variation: Decimal,
32    /// Disposal rate per year.
33    pub annual_disposal_rate: Decimal,
34}
35
36impl Default for FAGeneratorConfig {
37    fn default() -> Self {
38        Self {
39            default_depreciation_method: DepreciationMethod::StraightLine,
40            default_useful_life_months: 60,
41            salvage_value_percent: dec!(0.10),
42            avg_acquisition_cost: dec!(50000),
43            cost_variation: dec!(0.7),
44            annual_disposal_rate: dec!(0.05),
45        }
46    }
47}
48
49/// Generator for Fixed Assets transactions.
50pub struct FAGenerator {
51    config: FAGeneratorConfig,
52    rng: ChaCha8Rng,
53    asset_counter: u64,
54    depreciation_run_counter: u64,
55    disposal_counter: u64,
56}
57
58impl FAGenerator {
59    /// Creates a new FA generator.
60    pub fn new(config: FAGeneratorConfig, rng: ChaCha8Rng) -> Self {
61        Self {
62            config,
63            rng,
64            asset_counter: 0,
65            depreciation_run_counter: 0,
66            disposal_counter: 0,
67        }
68    }
69
70    /// Creates a new FA generator from a seed, constructing the RNG internally.
71    pub fn with_seed(config: FAGeneratorConfig, seed: u64) -> Self {
72        Self::new(config, seeded_rng(seed, 0))
73    }
74
75    /// Maps a string asset class to the enum.
76    fn parse_asset_class(class_str: &str) -> AssetClass {
77        match class_str.to_uppercase().as_str() {
78            "LAND" => AssetClass::Land,
79            "BUILDINGS" | "BUILDING" => AssetClass::Buildings,
80            "MACHINERY" | "EQUIPMENT" | "MACHINERY_EQUIPMENT" => AssetClass::MachineryEquipment,
81            "VEHICLES" | "VEHICLE" => AssetClass::Vehicles,
82            "FURNITURE" | "FIXTURES" => AssetClass::FurnitureFixtures,
83            "COMPUTER" | "IT" | "IT_EQUIPMENT" => AssetClass::ComputerEquipment,
84            "SOFTWARE" => AssetClass::Software,
85            "LEASEHOLD" | "LEASEHOLD_IMPROVEMENTS" => AssetClass::LeaseholdImprovements,
86            _ => AssetClass::Other,
87        }
88    }
89
90    /// Generates a new fixed asset acquisition.
91    pub fn generate_asset_acquisition(
92        &mut self,
93        company_code: &str,
94        asset_class_str: &str,
95        description: &str,
96        acquisition_date: NaiveDate,
97        currency: &str,
98        cost_center: Option<&str>,
99    ) -> (FixedAssetRecord, JournalEntry) {
100        debug!(company_code, asset_class_str, %acquisition_date, "Generating FA asset acquisition");
101        self.asset_counter += 1;
102        let asset_number = format!("FA{:08}", self.asset_counter);
103        let asset_class = Self::parse_asset_class(asset_class_str);
104
105        let acquisition_cost = self.generate_acquisition_cost();
106        let salvage_value = (acquisition_cost * self.config.salvage_value_percent).round_dp(2);
107
108        // Create the asset using the constructor
109        let mut asset = FixedAssetRecord::new(
110            asset_number,
111            company_code.to_string(),
112            asset_class,
113            description.to_string(),
114            acquisition_date,
115            acquisition_cost,
116            currency.to_string(),
117        );
118
119        // Add serial number and inventory number
120        asset.serial_number = Some(format!("SN-{:010}", self.rng.random::<u32>()));
121        asset.inventory_number = Some(format!("INV-{:08}", self.asset_counter));
122        asset.cost_center = cost_center.map(|s| s.to_string());
123
124        // Add a depreciation area
125        let mut depreciation_area = DepreciationArea::new(
126            DepreciationAreaType::Book,
127            self.config.default_depreciation_method,
128            self.config.default_useful_life_months,
129            acquisition_cost,
130        );
131        depreciation_area.salvage_value = salvage_value;
132        asset.add_depreciation_area(depreciation_area);
133
134        let je = self.generate_acquisition_je(&asset);
135        (asset, je)
136    }
137
138    /// Runs depreciation for a period.
139    pub fn run_depreciation(
140        &mut self,
141        company_code: &str,
142        assets: &[&FixedAssetRecord],
143        period_date: NaiveDate,
144        fiscal_year: i32,
145        fiscal_period: u32,
146    ) -> (DepreciationRun, Vec<JournalEntry>) {
147        self.depreciation_run_counter += 1;
148        let run_id = format!("DEPR{:08}", self.depreciation_run_counter);
149
150        let mut run = DepreciationRun::new(
151            run_id,
152            company_code.to_string(),
153            fiscal_year,
154            fiscal_period,
155            DepreciationAreaType::Book,
156            period_date,
157            "FAGenerator".to_string(),
158        );
159
160        run.start();
161        let mut journal_entries = Vec::new();
162
163        for asset in assets {
164            if asset.status != AssetStatus::Active {
165                continue;
166            }
167
168            // Create entry from asset using the from_asset method
169            if let Some(entry) = DepreciationEntry::from_asset(asset, DepreciationAreaType::Book) {
170                if entry.depreciation_amount <= Decimal::ZERO {
171                    continue;
172                }
173
174                let je = self.generate_depreciation_je(asset, &entry, period_date);
175                run.add_entry(entry);
176                journal_entries.push(je);
177            }
178        }
179
180        run.complete();
181        (run, journal_entries)
182    }
183
184    /// Generates an asset disposal.
185    pub fn generate_disposal(
186        &mut self,
187        asset: &FixedAssetRecord,
188        disposal_date: NaiveDate,
189        disposal_type: DisposalType,
190        proceeds: Decimal,
191    ) -> (AssetDisposal, JournalEntry) {
192        self.disposal_counter += 1;
193        let disposal_id = format!("DISP{:08}", self.disposal_counter);
194
195        let disposal_reason = self.random_disposal_reason();
196
197        // Use the appropriate constructor based on disposal type
198        let mut disposal = if disposal_type == DisposalType::Sale && proceeds > Decimal::ZERO {
199            AssetDisposal::sale(
200                disposal_id,
201                asset,
202                disposal_date,
203                proceeds,
204                format!("CUST-{}", self.disposal_counter),
205                "FAGenerator".to_string(),
206            )
207        } else {
208            let mut d = AssetDisposal::new(
209                disposal_id,
210                asset,
211                disposal_date,
212                disposal_type,
213                disposal_reason,
214                "FAGenerator".to_string(),
215            );
216            if proceeds > Decimal::ZERO {
217                d = d.with_sale_proceeds(proceeds);
218            } else {
219                d.calculate_gain_loss();
220            }
221            d
222        };
223
224        // Approve the disposal
225        disposal.approve("SYSTEM".to_string(), disposal_date);
226
227        let je = self.generate_disposal_je(asset, &disposal);
228        (disposal, je)
229    }
230
231    fn generate_acquisition_cost(&mut self) -> Decimal {
232        let base = self.config.avg_acquisition_cost;
233        let variation = base * self.config.cost_variation;
234        let random: f64 = self.rng.random_range(-1.0..1.0);
235        (base + variation * Decimal::try_from(random).unwrap_or_default())
236            .max(dec!(1000))
237            .round_dp(2)
238    }
239
240    fn random_disposal_reason(&mut self) -> DisposalReason {
241        match self.rng.random_range(0..5) {
242            0 => DisposalReason::Sale,
243            1 => DisposalReason::EndOfLife,
244            2 => DisposalReason::Obsolescence,
245            3 => DisposalReason::Donated,
246            _ => DisposalReason::Replacement,
247        }
248    }
249
250    fn generate_acquisition_je(&self, asset: &FixedAssetRecord) -> JournalEntry {
251        let mut je = JournalEntry::new_simple(
252            format!("JE-ACQ-{}", asset.asset_number),
253            asset.company_code.clone(),
254            asset.acquisition_date,
255            format!("Asset Acquisition {}", asset.asset_number),
256        );
257
258        // Debit Fixed Asset
259        je.add_line(JournalEntryLine {
260            line_number: 1,
261            gl_account: asset.account_determination.acquisition_account.clone(),
262            debit_amount: asset.acquisition_cost,
263            cost_center: asset.cost_center.clone(),
264            profit_center: asset.profit_center.clone(),
265            reference: Some(asset.asset_number.clone()),
266            text: Some(asset.description.clone()),
267            quantity: Some(dec!(1)),
268            unit: Some("EA".to_string()),
269            ..Default::default()
270        });
271
272        // Credit Cash/AP (assuming cash purchase)
273        je.add_line(JournalEntryLine {
274            line_number: 2,
275            gl_account: asset.account_determination.clearing_account.clone(),
276            credit_amount: asset.acquisition_cost,
277            reference: Some(asset.asset_number.clone()),
278            ..Default::default()
279        });
280
281        je
282    }
283
284    fn generate_depreciation_je(
285        &self,
286        asset: &FixedAssetRecord,
287        entry: &DepreciationEntry,
288        posting_date: NaiveDate,
289    ) -> JournalEntry {
290        let mut je = JournalEntry::new_simple(
291            format!("JE-DEP-{}", asset.asset_number),
292            asset.company_code.clone(),
293            posting_date,
294            format!("Depreciation {}", asset.asset_number),
295        );
296
297        // Debit Depreciation Expense
298        je.add_line(JournalEntryLine {
299            line_number: 1,
300            gl_account: entry.expense_account.clone(),
301            debit_amount: entry.depreciation_amount,
302            cost_center: asset.cost_center.clone(),
303            profit_center: asset.profit_center.clone(),
304            reference: Some(asset.asset_number.clone()),
305            ..Default::default()
306        });
307
308        // Credit Accumulated Depreciation
309        je.add_line(JournalEntryLine {
310            line_number: 2,
311            gl_account: entry.accum_depr_account.clone(),
312            credit_amount: entry.depreciation_amount,
313            reference: Some(asset.asset_number.clone()),
314            ..Default::default()
315        });
316
317        je
318    }
319
320    fn generate_disposal_je(
321        &self,
322        asset: &FixedAssetRecord,
323        disposal: &AssetDisposal,
324    ) -> JournalEntry {
325        let mut je = JournalEntry::new_simple(
326            format!("JE-{}", disposal.disposal_id),
327            asset.company_code.clone(),
328            disposal.disposal_date,
329            format!("Asset Disposal {}", asset.asset_number),
330        );
331
332        let mut line_num = 1;
333
334        // Debit Cash (if proceeds > 0)
335        if disposal.sale_proceeds > Decimal::ZERO {
336            je.add_line(JournalEntryLine {
337                line_number: line_num,
338                gl_account: "1000".to_string(),
339                debit_amount: disposal.sale_proceeds,
340                reference: Some(disposal.disposal_id.clone()),
341                ..Default::default()
342            });
343            line_num += 1;
344        }
345
346        // Debit Accumulated Depreciation
347        je.add_line(JournalEntryLine {
348            line_number: line_num,
349            gl_account: asset
350                .account_determination
351                .accumulated_depreciation_account
352                .clone(),
353            debit_amount: disposal.accumulated_depreciation,
354            reference: Some(disposal.disposal_id.clone()),
355            ..Default::default()
356        });
357        line_num += 1;
358
359        // Debit Loss on Disposal (if loss)
360        if !disposal.is_gain {
361            je.add_line(JournalEntryLine {
362                line_number: line_num,
363                gl_account: asset.account_determination.loss_on_disposal_account.clone(),
364                debit_amount: disposal.loss(),
365                cost_center: asset.cost_center.clone(),
366                profit_center: asset.profit_center.clone(),
367                reference: Some(disposal.disposal_id.clone()),
368                ..Default::default()
369            });
370            line_num += 1;
371        }
372
373        // Credit Fixed Asset
374        je.add_line(JournalEntryLine {
375            line_number: line_num,
376            gl_account: asset.account_determination.acquisition_account.clone(),
377            credit_amount: asset.acquisition_cost,
378            reference: Some(disposal.disposal_id.clone()),
379            ..Default::default()
380        });
381        line_num += 1;
382
383        // Credit Gain on Disposal (if gain)
384        if disposal.is_gain && disposal.gain() > Decimal::ZERO {
385            je.add_line(JournalEntryLine {
386                line_number: line_num,
387                gl_account: asset.account_determination.gain_on_disposal_account.clone(),
388                credit_amount: disposal.gain(),
389                cost_center: asset.cost_center.clone(),
390                profit_center: asset.profit_center.clone(),
391                reference: Some(disposal.disposal_id.clone()),
392                ..Default::default()
393            });
394        }
395
396        je
397    }
398}
399
400#[cfg(test)]
401#[allow(clippy::unwrap_used)]
402mod tests {
403    use super::*;
404    use datasynth_core::models::subledger::fa::DepreciationRunStatus;
405    use rand::SeedableRng;
406
407    #[test]
408    fn test_generate_asset_acquisition() {
409        let rng = ChaCha8Rng::seed_from_u64(12345);
410        let mut generator = FAGenerator::new(FAGeneratorConfig::default(), rng);
411
412        let (asset, je) = generator.generate_asset_acquisition(
413            "1000",
414            "MACHINERY",
415            "CNC Machine",
416            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
417            "USD",
418            Some("CC100"),
419        );
420
421        assert_eq!(asset.status, AssetStatus::Active);
422        assert!(asset.acquisition_cost > Decimal::ZERO);
423        assert!(je.is_balanced());
424    }
425
426    #[test]
427    fn test_run_depreciation() {
428        let rng = ChaCha8Rng::seed_from_u64(12345);
429        let mut generator = FAGenerator::new(FAGeneratorConfig::default(), rng);
430
431        let (asset, _) = generator.generate_asset_acquisition(
432            "1000",
433            "MACHINERY",
434            "CNC Machine",
435            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
436            "USD",
437            None,
438        );
439
440        let (run, jes) = generator.run_depreciation(
441            "1000",
442            &[&asset],
443            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
444            2024,
445            1,
446        );
447
448        assert_eq!(run.status, DepreciationRunStatus::Completed);
449        assert!(run.asset_count > 0);
450        assert!(jes.iter().all(|je| je.is_balanced()));
451    }
452}