Skip to main content

datasynth_generators/subledger/
fa_generator.rs

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