Skip to main content

datasynth_core/models/subledger/fa/
disposal.rs

1//! Asset disposal and retirement models.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use super::{AssetClass, FixedAssetRecord};
8use crate::models::subledger::GLReference;
9
10/// Asset disposal transaction.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AssetDisposal {
13    /// Disposal ID.
14    pub disposal_id: String,
15    /// Asset number.
16    pub asset_number: String,
17    /// Sub-number.
18    pub sub_number: String,
19    /// Company code.
20    pub company_code: String,
21    /// Disposal date.
22    pub disposal_date: NaiveDate,
23    /// Posting date.
24    pub posting_date: NaiveDate,
25    /// Disposal type.
26    pub disposal_type: DisposalType,
27    /// Disposal reason.
28    pub disposal_reason: DisposalReason,
29    /// Asset description.
30    pub asset_description: String,
31    /// Asset class.
32    pub asset_class: AssetClass,
33    /// Original acquisition cost.
34    pub acquisition_cost: Decimal,
35    /// Accumulated depreciation at disposal.
36    pub accumulated_depreciation: Decimal,
37    /// Net book value at disposal.
38    pub net_book_value: Decimal,
39    /// Sale proceeds (if sold).
40    pub sale_proceeds: Decimal,
41    /// Disposal costs (removal, transport, etc.).
42    pub disposal_costs: Decimal,
43    /// Net proceeds.
44    pub net_proceeds: Decimal,
45    /// Gain or loss on disposal.
46    pub gain_loss: Decimal,
47    /// Is gain (vs loss).
48    pub is_gain: bool,
49    /// Customer (if sold).
50    pub customer_id: Option<String>,
51    /// Invoice reference.
52    pub invoice_reference: Option<String>,
53    /// GL references.
54    pub gl_references: Vec<GLReference>,
55    /// Approval status.
56    pub approval_status: DisposalApprovalStatus,
57    /// Approved by.
58    pub approved_by: Option<String>,
59    /// Approval date.
60    pub approval_date: Option<NaiveDate>,
61    /// Created by.
62    pub created_by: String,
63    /// Created at.
64    pub created_at: DateTime<Utc>,
65    /// Notes.
66    pub notes: Option<String>,
67}
68
69impl AssetDisposal {
70    /// Creates a new disposal record.
71    pub fn new(
72        disposal_id: String,
73        asset: &FixedAssetRecord,
74        disposal_date: NaiveDate,
75        disposal_type: DisposalType,
76        disposal_reason: DisposalReason,
77        created_by: String,
78    ) -> Self {
79        Self {
80            disposal_id,
81            asset_number: asset.asset_number.clone(),
82            sub_number: asset.sub_number.clone(),
83            company_code: asset.company_code.clone(),
84            disposal_date,
85            posting_date: disposal_date,
86            disposal_type,
87            disposal_reason,
88            asset_description: asset.description.clone(),
89            asset_class: asset.asset_class,
90            acquisition_cost: asset.acquisition_cost,
91            accumulated_depreciation: asset.accumulated_depreciation,
92            net_book_value: asset.net_book_value,
93            sale_proceeds: Decimal::ZERO,
94            disposal_costs: Decimal::ZERO,
95            net_proceeds: Decimal::ZERO,
96            gain_loss: Decimal::ZERO,
97            is_gain: false,
98            customer_id: None,
99            invoice_reference: None,
100            gl_references: Vec::new(),
101            approval_status: DisposalApprovalStatus::Pending,
102            approved_by: None,
103            approval_date: None,
104            created_by,
105            created_at: Utc::now(),
106            notes: None,
107        }
108    }
109
110    /// Creates a sale disposal.
111    pub fn sale(
112        disposal_id: String,
113        asset: &FixedAssetRecord,
114        disposal_date: NaiveDate,
115        sale_proceeds: Decimal,
116        customer_id: String,
117        created_by: String,
118    ) -> Self {
119        let mut disposal = Self::new(
120            disposal_id,
121            asset,
122            disposal_date,
123            DisposalType::Sale,
124            DisposalReason::Sale,
125            created_by,
126        );
127
128        disposal.sale_proceeds = sale_proceeds;
129        disposal.customer_id = Some(customer_id);
130        disposal.calculate_gain_loss();
131        disposal
132    }
133
134    /// Creates a scrapping disposal.
135    pub fn scrap(
136        disposal_id: String,
137        asset: &FixedAssetRecord,
138        disposal_date: NaiveDate,
139        reason: DisposalReason,
140        created_by: String,
141    ) -> Self {
142        let mut disposal = Self::new(
143            disposal_id,
144            asset,
145            disposal_date,
146            DisposalType::Scrapping,
147            reason,
148            created_by,
149        );
150        disposal.calculate_gain_loss();
151        disposal
152    }
153
154    /// Sets sale proceeds.
155    pub fn with_sale_proceeds(mut self, proceeds: Decimal) -> Self {
156        self.sale_proceeds = proceeds;
157        self.calculate_gain_loss();
158        self
159    }
160
161    /// Sets disposal costs.
162    pub fn with_disposal_costs(mut self, costs: Decimal) -> Self {
163        self.disposal_costs = costs;
164        self.calculate_gain_loss();
165        self
166    }
167
168    /// Calculates gain or loss on disposal.
169    pub fn calculate_gain_loss(&mut self) {
170        self.net_proceeds = self.sale_proceeds - self.disposal_costs;
171        self.gain_loss = self.net_proceeds - self.net_book_value;
172        self.is_gain = self.gain_loss >= Decimal::ZERO;
173    }
174
175    /// Approves the disposal.
176    pub fn approve(&mut self, approver: String, approval_date: NaiveDate) {
177        self.approval_status = DisposalApprovalStatus::Approved;
178        self.approved_by = Some(approver);
179        self.approval_date = Some(approval_date);
180    }
181
182    /// Rejects the disposal.
183    pub fn reject(&mut self, reason: String) {
184        self.approval_status = DisposalApprovalStatus::Rejected;
185        self.notes = Some(format!(
186            "{}Rejected: {}",
187            self.notes
188                .as_ref()
189                .map(|n| format!("{}. ", n))
190                .unwrap_or_default(),
191            reason
192        ));
193    }
194
195    /// Posts the disposal.
196    pub fn post(&mut self) {
197        self.approval_status = DisposalApprovalStatus::Posted;
198    }
199
200    /// Adds a GL reference.
201    pub fn add_gl_reference(&mut self, reference: GLReference) {
202        self.gl_references.push(reference);
203    }
204
205    /// Gets the gain (or zero if loss).
206    pub fn gain(&self) -> Decimal {
207        if self.is_gain {
208            self.gain_loss
209        } else {
210            Decimal::ZERO
211        }
212    }
213
214    /// Gets the loss (or zero if gain).
215    pub fn loss(&self) -> Decimal {
216        if !self.is_gain {
217            self.gain_loss.abs()
218        } else {
219            Decimal::ZERO
220        }
221    }
222
223    /// Requires approval based on threshold.
224    pub fn requires_approval(&self, threshold: Decimal) -> bool {
225        self.net_book_value > threshold || self.gain_loss.abs() > threshold
226    }
227}
228
229/// Type of disposal.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
231pub enum DisposalType {
232    /// Sale to external party.
233    #[default]
234    Sale,
235    /// Intercompany transfer.
236    IntercompanyTransfer,
237    /// Scrapping/write-off.
238    Scrapping,
239    /// Trade-in.
240    TradeIn,
241    /// Donation.
242    Donation,
243    /// Loss (theft, destruction).
244    Loss,
245    /// Partial disposal.
246    PartialDisposal,
247}
248
249/// Reason for disposal.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
251pub enum DisposalReason {
252    /// Normal sale.
253    #[default]
254    Sale,
255    /// End of useful life.
256    EndOfLife,
257    /// Obsolete.
258    Obsolescence,
259    /// Damaged beyond repair.
260    Damage,
261    /// Theft or loss.
262    TheftLoss,
263    /// Replaced by new asset.
264    Replacement,
265    /// Business restructuring.
266    Restructuring,
267    /// Compliance/regulatory.
268    Compliance,
269    /// Environmental disposal.
270    Environmental,
271    /// Donated to charity.
272    Donated,
273    /// Other.
274    Other,
275}
276
277/// Disposal approval status.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
279pub enum DisposalApprovalStatus {
280    /// Pending approval.
281    #[default]
282    Pending,
283    /// Approved.
284    Approved,
285    /// Rejected.
286    Rejected,
287    /// Posted.
288    Posted,
289    /// Cancelled.
290    Cancelled,
291}
292
293/// Asset transfer (between locations or entities).
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct AssetTransfer {
296    /// Transfer ID.
297    pub transfer_id: String,
298    /// Asset number.
299    pub asset_number: String,
300    /// Sub-number.
301    pub sub_number: String,
302    /// Transfer date.
303    pub transfer_date: NaiveDate,
304    /// Transfer type.
305    pub transfer_type: TransferType,
306    /// From company code.
307    pub from_company: String,
308    /// To company code.
309    pub to_company: String,
310    /// From cost center.
311    pub from_cost_center: Option<String>,
312    /// To cost center.
313    pub to_cost_center: Option<String>,
314    /// From location.
315    pub from_location: Option<String>,
316    /// To location.
317    pub to_location: Option<String>,
318    /// Transfer value.
319    pub transfer_value: Decimal,
320    /// Accumulated depreciation transferred.
321    pub accumulated_depreciation: Decimal,
322    /// Status.
323    pub status: TransferStatus,
324    /// Created by.
325    pub created_by: String,
326    /// Created at.
327    pub created_at: DateTime<Utc>,
328    /// Notes.
329    pub notes: Option<String>,
330}
331
332impl AssetTransfer {
333    /// Creates a new asset transfer.
334    pub fn new(
335        transfer_id: String,
336        asset: &FixedAssetRecord,
337        transfer_date: NaiveDate,
338        transfer_type: TransferType,
339        to_company: String,
340        created_by: String,
341    ) -> Self {
342        Self {
343            transfer_id,
344            asset_number: asset.asset_number.clone(),
345            sub_number: asset.sub_number.clone(),
346            transfer_date,
347            transfer_type,
348            from_company: asset.company_code.clone(),
349            to_company,
350            from_cost_center: asset.cost_center.clone(),
351            to_cost_center: None,
352            from_location: asset.location.clone(),
353            to_location: None,
354            transfer_value: asset.net_book_value,
355            accumulated_depreciation: asset.accumulated_depreciation,
356            status: TransferStatus::Draft,
357            created_by,
358            created_at: Utc::now(),
359            notes: None,
360        }
361    }
362
363    /// Sets destination cost center.
364    pub fn to_cost_center(mut self, cost_center: String) -> Self {
365        self.to_cost_center = Some(cost_center);
366        self
367    }
368
369    /// Sets destination location.
370    pub fn to_location(mut self, location: String) -> Self {
371        self.to_location = Some(location);
372        self
373    }
374
375    /// Submits for approval.
376    pub fn submit(&mut self) {
377        self.status = TransferStatus::Submitted;
378    }
379
380    /// Approves the transfer.
381    pub fn approve(&mut self) {
382        self.status = TransferStatus::Approved;
383    }
384
385    /// Completes the transfer.
386    pub fn complete(&mut self) {
387        self.status = TransferStatus::Completed;
388    }
389
390    /// Is intercompany transfer.
391    pub fn is_intercompany(&self) -> bool {
392        self.from_company != self.to_company
393    }
394}
395
396/// Type of transfer.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398pub enum TransferType {
399    /// Within same company, different cost center.
400    IntraCompany,
401    /// Between legal entities.
402    InterCompany,
403    /// Physical location change only.
404    LocationChange,
405    /// Reorganization.
406    Reorganization,
407}
408
409/// Transfer status.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
411pub enum TransferStatus {
412    /// Draft.
413    Draft,
414    /// Submitted for approval.
415    Submitted,
416    /// Approved.
417    Approved,
418    /// Completed.
419    Completed,
420    /// Rejected.
421    Rejected,
422    /// Cancelled.
423    Cancelled,
424}
425
426/// Asset impairment.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct AssetImpairment {
429    /// Impairment ID.
430    pub impairment_id: String,
431    /// Asset number.
432    pub asset_number: String,
433    /// Company code.
434    pub company_code: String,
435    /// Impairment date.
436    pub impairment_date: NaiveDate,
437    /// Net book value before impairment.
438    pub nbv_before: Decimal,
439    /// Fair value/recoverable amount.
440    pub fair_value: Decimal,
441    /// Impairment loss.
442    pub impairment_loss: Decimal,
443    /// Net book value after impairment.
444    pub nbv_after: Decimal,
445    /// Impairment reason.
446    pub reason: ImpairmentReason,
447    /// Is reversal (impairment recovery).
448    pub is_reversal: bool,
449    /// GL reference.
450    pub gl_reference: Option<GLReference>,
451    /// Created by.
452    pub created_by: String,
453    /// Created at.
454    pub created_at: DateTime<Utc>,
455    /// Notes.
456    pub notes: Option<String>,
457}
458
459impl AssetImpairment {
460    /// Creates a new impairment.
461    pub fn new(
462        impairment_id: String,
463        asset: &FixedAssetRecord,
464        impairment_date: NaiveDate,
465        fair_value: Decimal,
466        reason: ImpairmentReason,
467        created_by: String,
468    ) -> Self {
469        let impairment_loss = (asset.net_book_value - fair_value).max(Decimal::ZERO);
470
471        Self {
472            impairment_id,
473            asset_number: asset.asset_number.clone(),
474            company_code: asset.company_code.clone(),
475            impairment_date,
476            nbv_before: asset.net_book_value,
477            fair_value,
478            impairment_loss,
479            nbv_after: fair_value,
480            reason,
481            is_reversal: false,
482            gl_reference: None,
483            created_by,
484            created_at: Utc::now(),
485            notes: None,
486        }
487    }
488
489    /// Creates an impairment reversal.
490    pub fn reversal(
491        impairment_id: String,
492        asset: &FixedAssetRecord,
493        impairment_date: NaiveDate,
494        new_fair_value: Decimal,
495        max_reversal: Decimal,
496        created_by: String,
497    ) -> Self {
498        let reversal_amount = (new_fair_value - asset.net_book_value).min(max_reversal);
499
500        Self {
501            impairment_id,
502            asset_number: asset.asset_number.clone(),
503            company_code: asset.company_code.clone(),
504            impairment_date,
505            nbv_before: asset.net_book_value,
506            fair_value: new_fair_value,
507            impairment_loss: -reversal_amount, // Negative for reversal
508            nbv_after: asset.net_book_value + reversal_amount,
509            reason: ImpairmentReason::ValueRecovery,
510            is_reversal: true,
511            gl_reference: None,
512            created_by,
513            created_at: Utc::now(),
514            notes: None,
515        }
516    }
517}
518
519/// Reason for impairment.
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521pub enum ImpairmentReason {
522    /// Physical damage.
523    PhysicalDamage,
524    /// Market decline.
525    MarketDecline,
526    /// Technological obsolescence.
527    TechnologyObsolescence,
528    /// Legal or regulatory changes.
529    RegulatoryChange,
530    /// Business restructuring.
531    Restructuring,
532    /// Asset held for sale.
533    HeldForSale,
534    /// Value recovery (reversal).
535    ValueRecovery,
536    /// Other.
537    Other,
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use rust_decimal_macros::dec;
544
545    fn create_test_asset() -> FixedAssetRecord {
546        let mut asset = FixedAssetRecord::new(
547            "ASSET001".to_string(),
548            "1000".to_string(),
549            AssetClass::MachineryEquipment,
550            "Production Machine".to_string(),
551            NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
552            dec!(100000),
553            "USD".to_string(),
554        );
555        asset.accumulated_depreciation = dec!(60000);
556        asset.net_book_value = dec!(40000);
557        asset
558    }
559
560    #[test]
561    fn test_disposal_sale_gain() {
562        let asset = create_test_asset();
563        let disposal = AssetDisposal::sale(
564            "DISP001".to_string(),
565            &asset,
566            NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
567            dec!(50000),
568            "CUST001".to_string(),
569            "USER1".to_string(),
570        );
571
572        assert_eq!(disposal.net_book_value, dec!(40000));
573        assert_eq!(disposal.sale_proceeds, dec!(50000));
574        assert_eq!(disposal.gain_loss, dec!(10000));
575        assert!(disposal.is_gain);
576    }
577
578    #[test]
579    fn test_disposal_sale_loss() {
580        let asset = create_test_asset();
581        let disposal = AssetDisposal::sale(
582            "DISP002".to_string(),
583            &asset,
584            NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
585            dec!(30000),
586            "CUST001".to_string(),
587            "USER1".to_string(),
588        );
589
590        assert_eq!(disposal.gain_loss, dec!(-10000));
591        assert!(!disposal.is_gain);
592        assert_eq!(disposal.loss(), dec!(10000));
593    }
594
595    #[test]
596    fn test_disposal_scrapping() {
597        let asset = create_test_asset();
598        let disposal = AssetDisposal::scrap(
599            "DISP003".to_string(),
600            &asset,
601            NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
602            DisposalReason::EndOfLife,
603            "USER1".to_string(),
604        );
605
606        assert_eq!(disposal.sale_proceeds, Decimal::ZERO);
607        assert_eq!(disposal.gain_loss, dec!(-40000));
608        assert!(!disposal.is_gain);
609    }
610
611    #[test]
612    fn test_asset_transfer() {
613        let asset = create_test_asset();
614        let transfer = AssetTransfer::new(
615            "TRF001".to_string(),
616            &asset,
617            NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
618            TransferType::InterCompany,
619            "2000".to_string(),
620            "USER1".to_string(),
621        )
622        .to_cost_center("CC200".to_string());
623
624        assert!(transfer.is_intercompany());
625        assert_eq!(transfer.transfer_value, dec!(40000));
626    }
627
628    #[test]
629    fn test_impairment() {
630        let asset = create_test_asset();
631        let impairment = AssetImpairment::new(
632            "IMP001".to_string(),
633            &asset,
634            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
635            dec!(25000),
636            ImpairmentReason::MarketDecline,
637            "USER1".to_string(),
638        );
639
640        assert_eq!(impairment.nbv_before, dec!(40000));
641        assert_eq!(impairment.fair_value, dec!(25000));
642        assert_eq!(impairment.impairment_loss, dec!(15000));
643        assert_eq!(impairment.nbv_after, dec!(25000));
644    }
645}