Skip to main content

datasynth_generators/subledger/
fa_generator.rs

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