use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use super::{AssetClass, FixedAssetRecord};
use crate::models::subledger::GLReference;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetDisposal {
pub disposal_id: String,
pub asset_number: String,
pub sub_number: String,
pub company_code: String,
pub disposal_date: NaiveDate,
pub posting_date: NaiveDate,
pub disposal_type: DisposalType,
pub disposal_reason: DisposalReason,
pub asset_description: String,
pub asset_class: AssetClass,
pub acquisition_cost: Decimal,
pub accumulated_depreciation: Decimal,
pub net_book_value: Decimal,
pub sale_proceeds: Decimal,
pub disposal_costs: Decimal,
pub net_proceeds: Decimal,
pub gain_loss: Decimal,
pub is_gain: bool,
pub customer_id: Option<String>,
pub invoice_reference: Option<String>,
pub gl_references: Vec<GLReference>,
pub approval_status: DisposalApprovalStatus,
pub approved_by: Option<String>,
pub approval_date: Option<NaiveDate>,
pub created_by: String,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub notes: Option<String>,
}
impl AssetDisposal {
pub fn new(
disposal_id: String,
asset: &FixedAssetRecord,
disposal_date: NaiveDate,
disposal_type: DisposalType,
disposal_reason: DisposalReason,
created_by: String,
) -> Self {
Self {
disposal_id,
asset_number: asset.asset_number.clone(),
sub_number: asset.sub_number.clone(),
company_code: asset.company_code.clone(),
disposal_date,
posting_date: disposal_date,
disposal_type,
disposal_reason,
asset_description: asset.description.clone(),
asset_class: asset.asset_class,
acquisition_cost: asset.acquisition_cost,
accumulated_depreciation: asset.accumulated_depreciation,
net_book_value: asset.net_book_value,
sale_proceeds: Decimal::ZERO,
disposal_costs: Decimal::ZERO,
net_proceeds: Decimal::ZERO,
gain_loss: Decimal::ZERO,
is_gain: false,
customer_id: None,
invoice_reference: None,
gl_references: Vec::new(),
approval_status: DisposalApprovalStatus::Pending,
approved_by: None,
approval_date: None,
created_by,
created_at: Utc::now(),
notes: None,
}
}
pub fn sale(
disposal_id: String,
asset: &FixedAssetRecord,
disposal_date: NaiveDate,
sale_proceeds: Decimal,
customer_id: String,
created_by: String,
) -> Self {
let mut disposal = Self::new(
disposal_id,
asset,
disposal_date,
DisposalType::Sale,
DisposalReason::Sale,
created_by,
);
disposal.sale_proceeds = sale_proceeds;
disposal.customer_id = Some(customer_id);
disposal.calculate_gain_loss();
disposal
}
pub fn scrap(
disposal_id: String,
asset: &FixedAssetRecord,
disposal_date: NaiveDate,
reason: DisposalReason,
created_by: String,
) -> Self {
let mut disposal = Self::new(
disposal_id,
asset,
disposal_date,
DisposalType::Scrapping,
reason,
created_by,
);
disposal.calculate_gain_loss();
disposal
}
pub fn with_sale_proceeds(mut self, proceeds: Decimal) -> Self {
self.sale_proceeds = proceeds;
self.calculate_gain_loss();
self
}
pub fn with_disposal_costs(mut self, costs: Decimal) -> Self {
self.disposal_costs = costs;
self.calculate_gain_loss();
self
}
pub fn calculate_gain_loss(&mut self) {
self.net_proceeds = self.sale_proceeds - self.disposal_costs;
self.gain_loss = self.net_proceeds - self.net_book_value;
self.is_gain = self.gain_loss >= Decimal::ZERO;
}
pub fn approve(&mut self, approver: String, approval_date: NaiveDate) {
self.approval_status = DisposalApprovalStatus::Approved;
self.approved_by = Some(approver);
self.approval_date = Some(approval_date);
}
pub fn reject(&mut self, reason: String) {
self.approval_status = DisposalApprovalStatus::Rejected;
self.notes = Some(format!(
"{}Rejected: {}",
self.notes
.as_ref()
.map(|n| format!("{n}. "))
.unwrap_or_default(),
reason
));
}
pub fn post(&mut self) {
self.approval_status = DisposalApprovalStatus::Posted;
}
pub fn add_gl_reference(&mut self, reference: GLReference) {
self.gl_references.push(reference);
}
pub fn gain(&self) -> Decimal {
if self.is_gain {
self.gain_loss
} else {
Decimal::ZERO
}
}
pub fn loss(&self) -> Decimal {
if !self.is_gain {
self.gain_loss.abs()
} else {
Decimal::ZERO
}
}
pub fn requires_approval(&self, threshold: Decimal) -> bool {
self.net_book_value > threshold || self.gain_loss.abs() > threshold
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DisposalType {
#[default]
Sale,
IntercompanyTransfer,
Scrapping,
TradeIn,
Donation,
Loss,
PartialDisposal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DisposalReason {
#[default]
Sale,
EndOfLife,
Obsolescence,
Damage,
TheftLoss,
Replacement,
Restructuring,
Compliance,
Environmental,
Donated,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DisposalApprovalStatus {
#[default]
Pending,
Approved,
Rejected,
Posted,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetTransfer {
pub transfer_id: String,
pub asset_number: String,
pub sub_number: String,
pub transfer_date: NaiveDate,
pub transfer_type: TransferType,
pub from_company: String,
pub to_company: String,
pub from_cost_center: Option<String>,
pub to_cost_center: Option<String>,
pub from_location: Option<String>,
pub to_location: Option<String>,
pub transfer_value: Decimal,
pub accumulated_depreciation: Decimal,
pub status: TransferStatus,
pub created_by: String,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub notes: Option<String>,
}
impl AssetTransfer {
pub fn new(
transfer_id: String,
asset: &FixedAssetRecord,
transfer_date: NaiveDate,
transfer_type: TransferType,
to_company: String,
created_by: String,
) -> Self {
Self {
transfer_id,
asset_number: asset.asset_number.clone(),
sub_number: asset.sub_number.clone(),
transfer_date,
transfer_type,
from_company: asset.company_code.clone(),
to_company,
from_cost_center: asset.cost_center.clone(),
to_cost_center: None,
from_location: asset.location.clone(),
to_location: None,
transfer_value: asset.net_book_value,
accumulated_depreciation: asset.accumulated_depreciation,
status: TransferStatus::Draft,
created_by,
created_at: Utc::now(),
notes: None,
}
}
pub fn to_cost_center(mut self, cost_center: String) -> Self {
self.to_cost_center = Some(cost_center);
self
}
pub fn to_location(mut self, location: String) -> Self {
self.to_location = Some(location);
self
}
pub fn submit(&mut self) {
self.status = TransferStatus::Submitted;
}
pub fn approve(&mut self) {
self.status = TransferStatus::Approved;
}
pub fn complete(&mut self) {
self.status = TransferStatus::Completed;
}
pub fn is_intercompany(&self) -> bool {
self.from_company != self.to_company
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransferType {
IntraCompany,
InterCompany,
LocationChange,
Reorganization,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransferStatus {
Draft,
Submitted,
Approved,
Completed,
Rejected,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetImpairment {
pub impairment_id: String,
pub asset_number: String,
pub company_code: String,
pub impairment_date: NaiveDate,
pub nbv_before: Decimal,
pub fair_value: Decimal,
pub impairment_loss: Decimal,
pub nbv_after: Decimal,
pub reason: ImpairmentReason,
pub is_reversal: bool,
pub gl_reference: Option<GLReference>,
pub created_by: String,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub notes: Option<String>,
}
impl AssetImpairment {
pub fn new(
impairment_id: String,
asset: &FixedAssetRecord,
impairment_date: NaiveDate,
fair_value: Decimal,
reason: ImpairmentReason,
created_by: String,
) -> Self {
let impairment_loss = (asset.net_book_value - fair_value).max(Decimal::ZERO);
Self {
impairment_id,
asset_number: asset.asset_number.clone(),
company_code: asset.company_code.clone(),
impairment_date,
nbv_before: asset.net_book_value,
fair_value,
impairment_loss,
nbv_after: fair_value,
reason,
is_reversal: false,
gl_reference: None,
created_by,
created_at: Utc::now(),
notes: None,
}
}
pub fn reversal(
impairment_id: String,
asset: &FixedAssetRecord,
impairment_date: NaiveDate,
new_fair_value: Decimal,
max_reversal: Decimal,
created_by: String,
) -> Self {
let reversal_amount = (new_fair_value - asset.net_book_value).min(max_reversal);
Self {
impairment_id,
asset_number: asset.asset_number.clone(),
company_code: asset.company_code.clone(),
impairment_date,
nbv_before: asset.net_book_value,
fair_value: new_fair_value,
impairment_loss: -reversal_amount, nbv_after: asset.net_book_value + reversal_amount,
reason: ImpairmentReason::ValueRecovery,
is_reversal: true,
gl_reference: None,
created_by,
created_at: Utc::now(),
notes: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImpairmentReason {
PhysicalDamage,
MarketDecline,
TechnologyObsolescence,
RegulatoryChange,
Restructuring,
HeldForSale,
ValueRecovery,
Other,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn create_test_asset() -> FixedAssetRecord {
let mut asset = FixedAssetRecord::new(
"ASSET001".to_string(),
"1000".to_string(),
AssetClass::MachineryEquipment,
"Production Machine".to_string(),
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
dec!(100000),
"USD".to_string(),
);
asset.accumulated_depreciation = dec!(60000);
asset.net_book_value = dec!(40000);
asset
}
#[test]
fn test_disposal_sale_gain() {
let asset = create_test_asset();
let disposal = AssetDisposal::sale(
"DISP001".to_string(),
&asset,
NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
dec!(50000),
"CUST001".to_string(),
"USER1".to_string(),
);
assert_eq!(disposal.net_book_value, dec!(40000));
assert_eq!(disposal.sale_proceeds, dec!(50000));
assert_eq!(disposal.gain_loss, dec!(10000));
assert!(disposal.is_gain);
}
#[test]
fn test_disposal_sale_loss() {
let asset = create_test_asset();
let disposal = AssetDisposal::sale(
"DISP002".to_string(),
&asset,
NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
dec!(30000),
"CUST001".to_string(),
"USER1".to_string(),
);
assert_eq!(disposal.gain_loss, dec!(-10000));
assert!(!disposal.is_gain);
assert_eq!(disposal.loss(), dec!(10000));
}
#[test]
fn test_disposal_scrapping() {
let asset = create_test_asset();
let disposal = AssetDisposal::scrap(
"DISP003".to_string(),
&asset,
NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
DisposalReason::EndOfLife,
"USER1".to_string(),
);
assert_eq!(disposal.sale_proceeds, Decimal::ZERO);
assert_eq!(disposal.gain_loss, dec!(-40000));
assert!(!disposal.is_gain);
}
#[test]
fn test_asset_transfer() {
let asset = create_test_asset();
let transfer = AssetTransfer::new(
"TRF001".to_string(),
&asset,
NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
TransferType::InterCompany,
"2000".to_string(),
"USER1".to_string(),
)
.to_cost_center("CC200".to_string());
assert!(transfer.is_intercompany());
assert_eq!(transfer.transfer_value, dec!(40000));
}
#[test]
fn test_impairment() {
let asset = create_test_asset();
let impairment = AssetImpairment::new(
"IMP001".to_string(),
&asset,
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
dec!(25000),
ImpairmentReason::MarketDecline,
"USER1".to_string(),
);
assert_eq!(impairment.nbv_before, dec!(40000));
assert_eq!(impairment.fair_value, dec!(25000));
assert_eq!(impairment.impairment_loss, dec!(15000));
assert_eq!(impairment.nbv_after, dec!(25000));
}
}