1use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use super::{AssetClass, FixedAssetRecord};
8use crate::models::subledger::GLReference;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AssetDisposal {
13 pub disposal_id: String,
15 pub asset_number: String,
17 pub sub_number: String,
19 pub company_code: String,
21 pub disposal_date: NaiveDate,
23 pub posting_date: NaiveDate,
25 pub disposal_type: DisposalType,
27 pub disposal_reason: DisposalReason,
29 pub asset_description: String,
31 pub asset_class: AssetClass,
33 pub acquisition_cost: Decimal,
35 pub accumulated_depreciation: Decimal,
37 pub net_book_value: Decimal,
39 pub sale_proceeds: Decimal,
41 pub disposal_costs: Decimal,
43 pub net_proceeds: Decimal,
45 pub gain_loss: Decimal,
47 pub is_gain: bool,
49 pub customer_id: Option<String>,
51 pub invoice_reference: Option<String>,
53 pub gl_references: Vec<GLReference>,
55 pub approval_status: DisposalApprovalStatus,
57 pub approved_by: Option<String>,
59 pub approval_date: Option<NaiveDate>,
61 pub created_by: String,
63 #[serde(with = "crate::serde_timestamp::utc")]
65 pub created_at: DateTime<Utc>,
66 pub notes: Option<String>,
68}
69
70impl AssetDisposal {
71 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 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 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 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 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 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 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 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 pub fn post(&mut self) {
198 self.approval_status = DisposalApprovalStatus::Posted;
199 }
200
201 pub fn add_gl_reference(&mut self, reference: GLReference) {
203 self.gl_references.push(reference);
204 }
205
206 pub fn gain(&self) -> Decimal {
208 if self.is_gain {
209 self.gain_loss
210 } else {
211 Decimal::ZERO
212 }
213 }
214
215 pub fn loss(&self) -> Decimal {
217 if !self.is_gain {
218 self.gain_loss.abs()
219 } else {
220 Decimal::ZERO
221 }
222 }
223
224 pub fn requires_approval(&self, threshold: Decimal) -> bool {
226 self.net_book_value > threshold || self.gain_loss.abs() > threshold
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
232pub enum DisposalType {
233 #[default]
235 Sale,
236 IntercompanyTransfer,
238 Scrapping,
240 TradeIn,
242 Donation,
244 Loss,
246 PartialDisposal,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
252pub enum DisposalReason {
253 #[default]
255 Sale,
256 EndOfLife,
258 Obsolescence,
260 Damage,
262 TheftLoss,
264 Replacement,
266 Restructuring,
268 Compliance,
270 Environmental,
272 Donated,
274 Other,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
280pub enum DisposalApprovalStatus {
281 #[default]
283 Pending,
284 Approved,
286 Rejected,
288 Posted,
290 Cancelled,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct AssetTransfer {
297 pub transfer_id: String,
299 pub asset_number: String,
301 pub sub_number: String,
303 pub transfer_date: NaiveDate,
305 pub transfer_type: TransferType,
307 pub from_company: String,
309 pub to_company: String,
311 pub from_cost_center: Option<String>,
313 pub to_cost_center: Option<String>,
315 pub from_location: Option<String>,
317 pub to_location: Option<String>,
319 pub transfer_value: Decimal,
321 pub accumulated_depreciation: Decimal,
323 pub status: TransferStatus,
325 pub created_by: String,
327 #[serde(with = "crate::serde_timestamp::utc")]
329 pub created_at: DateTime<Utc>,
330 pub notes: Option<String>,
332}
333
334impl AssetTransfer {
335 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 pub fn to_cost_center(mut self, cost_center: String) -> Self {
367 self.to_cost_center = Some(cost_center);
368 self
369 }
370
371 pub fn to_location(mut self, location: String) -> Self {
373 self.to_location = Some(location);
374 self
375 }
376
377 pub fn submit(&mut self) {
379 self.status = TransferStatus::Submitted;
380 }
381
382 pub fn approve(&mut self) {
384 self.status = TransferStatus::Approved;
385 }
386
387 pub fn complete(&mut self) {
389 self.status = TransferStatus::Completed;
390 }
391
392 pub fn is_intercompany(&self) -> bool {
394 self.from_company != self.to_company
395 }
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400pub enum TransferType {
401 IntraCompany,
403 InterCompany,
405 LocationChange,
407 Reorganization,
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
413pub enum TransferStatus {
414 Draft,
416 Submitted,
418 Approved,
420 Completed,
422 Rejected,
424 Cancelled,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct AssetImpairment {
431 pub impairment_id: String,
433 pub asset_number: String,
435 pub company_code: String,
437 pub impairment_date: NaiveDate,
439 pub nbv_before: Decimal,
441 pub fair_value: Decimal,
443 pub impairment_loss: Decimal,
445 pub nbv_after: Decimal,
447 pub reason: ImpairmentReason,
449 pub is_reversal: bool,
451 pub gl_reference: Option<GLReference>,
453 pub created_by: String,
455 #[serde(with = "crate::serde_timestamp::utc")]
457 pub created_at: DateTime<Utc>,
458 pub notes: Option<String>,
460}
461
462impl AssetImpairment {
463 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 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, 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
524pub enum ImpairmentReason {
525 PhysicalDamage,
527 MarketDecline,
529 TechnologyObsolescence,
531 RegulatoryChange,
533 Restructuring,
535 HeldForSale,
537 ValueRecovery,
539 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}