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