use crate::error::PortfolioError;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum IncomeSource {
Staking,
Mining,
Airdrop,
Interest,
Other,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Transaction {
Buy {
timestamp: DateTime<Utc>,
wallet: String,
asset: String,
quantity: Decimal,
unit_price: Decimal,
fee: Decimal,
},
Sell {
timestamp: DateTime<Utc>,
wallet: String,
asset: String,
quantity: Decimal,
unit_price: Decimal,
fee: Decimal,
},
Trade {
timestamp: DateTime<Utc>,
wallet: String,
from_asset: String,
from_quantity: Decimal,
to_asset: String,
to_quantity: Decimal,
value: Decimal,
fee: Decimal,
},
Income {
timestamp: DateTime<Utc>,
wallet: String,
asset: String,
quantity: Decimal,
value: Decimal,
source: IncomeSource,
},
Spend {
timestamp: DateTime<Utc>,
wallet: String,
asset: String,
quantity: Decimal,
value: Decimal,
fee: Decimal,
},
Transfer {
timestamp: DateTime<Utc>,
asset: String,
quantity: Decimal,
from_wallet: String,
to_wallet: String,
fee: Decimal,
fee_value: Decimal,
},
GiftSent {
timestamp: DateTime<Utc>,
wallet: String,
asset: String,
quantity: Decimal,
},
GiftReceived {
timestamp: DateTime<Utc>,
wallet: String,
asset: String,
quantity: Decimal,
donor_basis: Decimal,
fmv_at_receipt: Decimal,
donor_acquired_at: DateTime<Utc>,
},
}
impl Transaction {
pub fn timestamp(&self) -> DateTime<Utc> {
match self {
Transaction::Buy { timestamp, .. }
| Transaction::Sell { timestamp, .. }
| Transaction::Trade { timestamp, .. }
| Transaction::Income { timestamp, .. }
| Transaction::Spend { timestamp, .. }
| Transaction::Transfer { timestamp, .. }
| Transaction::GiftSent { timestamp, .. }
| Transaction::GiftReceived { timestamp, .. } => *timestamp,
}
}
pub fn validate(&self) -> Result<(), PortfolioError> {
let pos_qty = |asset: &str, q: Decimal| -> Result<(), PortfolioError> {
if q <= Decimal::ZERO {
Err(PortfolioError::NonPositiveQuantity {
asset: asset.to_string(),
quantity: q,
})
} else {
Ok(())
}
};
let non_neg_val = |asset: &str, v: Decimal| -> Result<(), PortfolioError> {
if v < Decimal::ZERO {
Err(PortfolioError::NegativeValue {
asset: asset.to_string(),
})
} else {
Ok(())
}
};
let non_neg_fee = |asset: &str, f: Decimal| -> Result<(), PortfolioError> {
if f < Decimal::ZERO {
Err(PortfolioError::NegativeFee {
asset: asset.to_string(),
fee: f,
})
} else {
Ok(())
}
};
match self {
Transaction::Buy {
asset,
quantity,
unit_price,
fee,
..
}
| Transaction::Sell {
asset,
quantity,
unit_price,
fee,
..
} => {
pos_qty(asset, *quantity)?;
non_neg_val(asset, *unit_price)?;
non_neg_fee(asset, *fee)
}
Transaction::Trade {
from_asset,
from_quantity,
to_asset,
to_quantity,
value,
fee,
..
} => {
pos_qty(from_asset, *from_quantity)?;
pos_qty(to_asset, *to_quantity)?;
non_neg_val(from_asset, *value)?;
non_neg_fee(from_asset, *fee)
}
Transaction::Income {
asset,
quantity,
value,
..
} => {
pos_qty(asset, *quantity)?;
non_neg_val(asset, *value)
}
Transaction::Spend {
asset,
quantity,
value,
fee,
..
} => {
pos_qty(asset, *quantity)?;
non_neg_val(asset, *value)?;
non_neg_fee(asset, *fee)
}
Transaction::Transfer {
asset,
quantity,
fee,
fee_value,
..
} => {
pos_qty(asset, *quantity)?;
non_neg_fee(asset, *fee)?;
non_neg_val(asset, *fee_value)
}
Transaction::GiftSent {
asset, quantity, ..
} => pos_qty(asset, *quantity),
Transaction::GiftReceived {
asset,
quantity,
donor_basis,
fmv_at_receipt,
..
} => {
pos_qty(asset, *quantity)?;
non_neg_val(asset, *donor_basis)?;
non_neg_val(asset, *fmv_at_receipt)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::PortfolioError;
use chrono::{TimeZone, Utc};
use rust_decimal_macros::dec;
fn ts(y: i32, m: u32, d: u32) -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap()
}
#[test]
fn timestamp_accessor_works_for_each_variant() {
let b = Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "bitcoin".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
};
assert_eq!(b.timestamp(), ts(2021, 1, 1));
let t = Transaction::Transfer {
timestamp: ts(2021, 2, 2),
asset: "bitcoin".into(),
quantity: dec!(1),
from_wallet: "a".into(),
to_wallet: "b".into(),
fee: dec!(0),
fee_value: dec!(0),
};
assert_eq!(t.timestamp(), ts(2021, 2, 2));
}
#[test]
fn validate_rejects_non_positive_quantity() {
let b = Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(0),
unit_price: dec!(100),
fee: dec!(0),
};
assert_eq!(
b.validate(),
Err(PortfolioError::NonPositiveQuantity {
asset: "eth".into(),
quantity: dec!(0)
})
);
}
#[test]
fn validate_rejects_negative_fee() {
let s = Transaction::Sell {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(-1),
};
assert_eq!(
s.validate(),
Err(PortfolioError::NegativeFee {
asset: "eth".into(),
fee: dec!(-1)
})
);
}
#[test]
fn validate_accepts_well_formed_event() {
let b = Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(1),
};
assert_eq!(b.validate(), Ok(()));
}
#[test]
fn validate_rejects_negative_unit_price() {
let b = Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(-1),
fee: dec!(0),
};
assert_eq!(
b.validate(),
Err(PortfolioError::NegativeValue {
asset: "btc".into()
})
);
}
#[test]
fn validate_rejects_negative_gift_fmv() {
let g = Transaction::GiftReceived {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
donor_basis: dec!(10),
fmv_at_receipt: dec!(-5),
donor_acquired_at: ts(2018, 1, 1),
};
assert_eq!(
g.validate(),
Err(PortfolioError::NegativeValue {
asset: "btc".into()
})
);
}
#[test]
fn validate_rejects_negative_transfer_fee_value() {
let t = Transaction::Transfer {
timestamp: ts(2021, 1, 1),
asset: "btc".into(),
quantity: dec!(1),
from_wallet: "a".into(),
to_wallet: "b".into(),
fee: dec!(1),
fee_value: dec!(-1),
};
assert_eq!(
t.validate(),
Err(PortfolioError::NegativeValue {
asset: "btc".into()
})
);
}
#[test]
fn validate_rejects_non_positive_trade_leg() {
let tr = Transaction::Trade {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
from_asset: "btc".into(),
from_quantity: dec!(0),
to_asset: "eth".into(),
to_quantity: dec!(10),
value: dec!(500),
fee: dec!(0),
};
assert_eq!(
tr.validate(),
Err(PortfolioError::NonPositiveQuantity {
asset: "btc".into(),
quantity: dec!(0)
})
);
}
#[test]
fn validate_accepts_well_formed_gift_and_transfer() {
let g = Transaction::GiftReceived {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
donor_basis: dec!(10),
fmv_at_receipt: dec!(12),
donor_acquired_at: ts(2018, 1, 1),
};
assert_eq!(g.validate(), Ok(()));
let t = Transaction::Transfer {
timestamp: ts(2021, 1, 1),
asset: "btc".into(),
quantity: dec!(1),
from_wallet: "a".into(),
to_wallet: "b".into(),
fee: dec!(0),
fee_value: dec!(0),
};
assert_eq!(t.validate(), Ok(()));
}
#[test]
fn validate_trade_rejects_invalid_to_quantity_value_and_fee() {
let tr = Transaction::Trade {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
from_asset: "btc".into(),
from_quantity: dec!(1),
to_asset: "eth".into(),
to_quantity: dec!(0),
value: dec!(500),
fee: dec!(0),
};
assert_eq!(
tr.validate(),
Err(PortfolioError::NonPositiveQuantity {
asset: "eth".into(),
quantity: dec!(0)
})
);
let tr2 = Transaction::Trade {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
from_asset: "btc".into(),
from_quantity: dec!(1),
to_asset: "eth".into(),
to_quantity: dec!(10),
value: dec!(-1),
fee: dec!(0),
};
assert_eq!(
tr2.validate(),
Err(PortfolioError::NegativeValue {
asset: "btc".into()
})
);
let tr3 = Transaction::Trade {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
from_asset: "btc".into(),
from_quantity: dec!(1),
to_asset: "eth".into(),
to_quantity: dec!(10),
value: dec!(500),
fee: dec!(-5),
};
assert_eq!(
tr3.validate(),
Err(PortfolioError::NegativeFee {
asset: "btc".into(),
fee: dec!(-5)
})
);
}
#[test]
fn validate_spend_rejects_invalid_fields() {
let s = Transaction::Spend {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(0),
value: dec!(100),
fee: dec!(0),
};
assert_eq!(
s.validate(),
Err(PortfolioError::NonPositiveQuantity {
asset: "btc".into(),
quantity: dec!(0)
})
);
let s2 = Transaction::Spend {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
value: dec!(-1),
fee: dec!(0),
};
assert_eq!(
s2.validate(),
Err(PortfolioError::NegativeValue {
asset: "btc".into()
})
);
let s3 = Transaction::Spend {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
value: dec!(100),
fee: dec!(-1),
};
assert_eq!(
s3.validate(),
Err(PortfolioError::NegativeFee {
asset: "btc".into(),
fee: dec!(-1)
})
);
}
#[test]
fn validate_gift_sent_rejects_non_positive_quantity() {
let g = Transaction::GiftSent {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(0),
};
assert_eq!(
g.validate(),
Err(PortfolioError::NonPositiveQuantity {
asset: "btc".into(),
quantity: dec!(0)
})
);
}
}