use crate::conventions::{Sat, TaxDate, Usd};
use crate::identity::{EventId, Fingerprint, LotId, WalletId};
use crate::LotMethod;
use serde::{Deserialize, Serialize};
use time::{OffsetDateTime, UtcOffset};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FmvStatus {
ExchangeProvided,
PriceDataset,
ManualEntry,
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BasisSource {
ExchangeProvided,
ComputedFromCost,
FmvAtIncome,
CarriedFromTransfer,
GiftCarryover,
GiftFmvFallback,
SafeHarborAllocated,
ReconstructedPerWallet,
SelfTransferInbound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum IncomeKind {
Mining,
Staking,
Interest,
Airdrop,
Reward,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DisposeKind {
Sell,
Spend,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Acquire {
pub sat: Sat,
pub usd_cost: Usd,
pub fee_usd: Usd,
pub basis_source: BasisSource,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Income {
pub sat: Sat,
pub usd_fmv: Option<Usd>,
pub fmv_status: FmvStatus,
pub kind: IncomeKind,
pub business: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Dispose {
pub sat: Sat,
pub usd_proceeds: Usd, pub fee_usd: Usd,
pub kind: DisposeKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferOut {
pub sat: Sat,
pub fee_sat: Option<Sat>,
pub dest_addr: Option<String>,
pub txid: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferIn {
pub sat: Sat,
pub src_addr: Option<String>,
pub txid: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Unclassified {
pub raw: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportConflict {
pub target: EventId,
pub new_payload: Box<EventPayload>,
pub new_fingerprint: Fingerprint,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransferTarget {
InEvent(EventId),
Wallet(WalletId),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferLink {
pub out_event: EventId,
pub in_event_or_wallet: TransferTarget,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OutflowClass {
Dispose { kind: DisposeKind },
GiftOut,
Donate { appraisal_required: bool },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReclassifyOutflow {
pub transfer_out_event: EventId,
pub as_: OutflowClass,
pub principal_proceeds_or_fmv: Usd,
pub fee_usd: Option<Usd>, #[serde(default)]
pub donee: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum InboundClass {
Income {
kind: IncomeKind,
fmv: Option<Usd>,
business: bool,
},
GiftReceived {
donor_basis: Option<Usd>,
donor_acquired_at: Option<TaxDate>,
fmv_at_gift: Usd,
},
SelfTransferMine {
#[serde(default)]
basis: Option<Usd>,
#[serde(default)]
acquired_at: Option<TaxDate>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClassifyInbound {
pub transfer_in_event: EventId,
pub as_: InboundClass,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManualFmv {
pub event: EventId,
pub usd_fmv: Usd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AllocMethod {
ActualPosition,
ProRata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AllocLot {
pub wallet: WalletId,
pub sat: Sat,
pub usd_basis: Usd, pub acquired_at: TaxDate, #[serde(default)]
pub dual_loss_basis: Option<Usd>, #[serde(default)]
pub donor_acquired_at: Option<TaxDate>, }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SafeHarborAllocation {
pub lots: Vec<AllocLot>,
pub as_of_date: TaxDate, pub method: AllocMethod,
pub timely_allocation_attested: bool,
#[serde(default)]
pub pre2025_method: LotMethod,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SupersedeImport {
pub conflict_event: EventId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RejectImport {
pub conflict_event: EventId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VoidDecisionEvent {
pub target_event_id: EventId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClassifyRaw {
pub target: EventId,
pub as_: Box<EventPayload>, }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReclassifyIncome {
pub income_event: EventId,
pub business: bool,
#[serde(default)]
pub kind: Option<IncomeKind>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct LotPick {
pub lot: LotId,
pub sat: Sat,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MethodElection {
pub effective_from: TaxDate,
pub method: LotMethod,
#[serde(default)]
pub wallet: Option<WalletId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SelfTransferPassthrough {
pub in_event: EventId,
pub out_event: EventId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LotSelection {
pub disposal_event: EventId,
pub lots: Vec<LotPick>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventPayload {
Acquire(Acquire),
Income(Income),
Dispose(Dispose),
TransferOut(TransferOut),
TransferIn(TransferIn),
Unclassified(Unclassified),
ImportConflict(ImportConflict),
TransferLink(TransferLink),
ReclassifyOutflow(ReclassifyOutflow),
ClassifyInbound(ClassifyInbound),
ManualFmv(ManualFmv),
SafeHarborAllocation(SafeHarborAllocation),
SupersedeImport(SupersedeImport),
RejectImport(RejectImport),
VoidDecisionEvent(VoidDecisionEvent),
ClassifyRaw(ClassifyRaw),
MethodElection(MethodElection),
LotSelection(LotSelection),
ReclassifyIncome(ReclassifyIncome),
SelfTransferPassthrough(SelfTransferPassthrough),
}
impl EventPayload {
pub fn is_imported(&self) -> bool {
matches!(
self,
EventPayload::Acquire(_)
| EventPayload::Income(_)
| EventPayload::Dispose(_)
| EventPayload::TransferOut(_)
| EventPayload::TransferIn(_)
| EventPayload::Unclassified(_)
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LedgerEvent {
pub id: EventId,
#[serde(with = "time::serde::rfc3339")]
pub utc_timestamp: OffsetDateTime,
pub original_tz: UtcOffset,
pub wallet: Option<WalletId>,
pub payload: EventPayload,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{EventId, Source, SourceRef};
use rust_decimal_macros::dec;
use time::macros::{datetime, offset};
fn sample(payload: EventPayload) -> LedgerEvent {
LedgerEvent {
id: EventId::import(Source::Coinbase, SourceRef::new("X")),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: offset!(-05:00),
wallet: Some(crate::identity::WalletId::Exchange {
provider: "coinbase".into(),
account: "main".into(),
}),
payload,
}
}
#[test]
fn every_variant_serde_round_trips() {
let payloads = vec![
EventPayload::Acquire(Acquire {
sat: 100_000,
usd_cost: dec!(60.00),
fee_usd: dec!(1.00),
basis_source: BasisSource::ExchangeProvided,
}),
EventPayload::Income(Income {
sat: 50_000,
usd_fmv: Some(dec!(30.00)),
fmv_status: FmvStatus::PriceDataset,
kind: IncomeKind::Interest,
business: false,
}),
EventPayload::Dispose(Dispose {
sat: 25_000,
usd_proceeds: dec!(40.00),
fee_usd: dec!(0.50),
kind: DisposeKind::Sell,
}),
EventPayload::TransferOut(TransferOut {
sat: 10_000,
fee_sat: Some(150),
dest_addr: Some("bc1q…".into()),
txid: Some("ab12".into()),
}),
EventPayload::TransferIn(TransferIn {
sat: 10_000,
src_addr: None,
txid: Some("ab12".into()),
}),
EventPayload::Unclassified(Unclassified {
raw: "weird row".into(),
}),
EventPayload::ImportConflict(ImportConflict {
target: EventId::import(Source::Coinbase, SourceRef::new("Y")),
new_payload: Box::new(EventPayload::Acquire(Acquire {
sat: 50_000,
usd_cost: dec!(30.00),
fee_usd: dec!(0.75),
basis_source: BasisSource::ComputedFromCost,
})),
new_fingerprint: Fingerprint::of_bytes(&[1u8; 32]),
}),
EventPayload::TransferLink(TransferLink {
out_event: EventId::import(Source::Coinbase, SourceRef::new("Z")),
in_event_or_wallet: TransferTarget::Wallet(crate::identity::WalletId::Exchange {
provider: "kraken".into(),
account: "trading".into(),
}),
}),
EventPayload::ReclassifyOutflow(ReclassifyOutflow {
transfer_out_event: EventId::import(Source::Coinbase, SourceRef::new("W")),
as_: OutflowClass::Dispose {
kind: DisposeKind::Spend,
},
principal_proceeds_or_fmv: dec!(150.00),
fee_usd: Some(dec!(2.50)),
donee: None,
}),
EventPayload::ClassifyInbound(ClassifyInbound {
transfer_in_event: EventId::import(Source::Coinbase, SourceRef::new("V")),
as_: InboundClass::Income {
kind: IncomeKind::Staking,
fmv: Some(dec!(45.50)),
business: true,
},
}),
EventPayload::ManualFmv(ManualFmv {
event: EventId::import(Source::Coinbase, SourceRef::new("U")),
usd_fmv: dec!(125.75),
}),
EventPayload::SafeHarborAllocation(SafeHarborAllocation {
lots: vec![
AllocLot {
wallet: crate::identity::WalletId::Exchange {
provider: "coinbase".into(),
account: "cold".into(),
},
sat: 50_000,
usd_basis: dec!(35.00),
acquired_at: time::Date::from_calendar_date(2024, time::Month::January, 15)
.unwrap(),
dual_loss_basis: None,
donor_acquired_at: None,
},
AllocLot {
wallet: crate::identity::WalletId::Exchange {
provider: "kraken".into(),
account: "main".into(),
},
sat: 30_000,
usd_basis: dec!(21.00),
acquired_at: time::Date::from_calendar_date(2024, time::Month::February, 1)
.unwrap(),
dual_loss_basis: None,
donor_acquired_at: None,
},
],
as_of_date: time::Date::from_calendar_date(2025, time::Month::January, 1).unwrap(),
method: AllocMethod::ProRata,
timely_allocation_attested: true,
pre2025_method: LotMethod::Fifo,
}),
EventPayload::SupersedeImport(SupersedeImport {
conflict_event: EventId::import(Source::Coinbase, SourceRef::new("T")),
}),
EventPayload::RejectImport(RejectImport {
conflict_event: EventId::import(Source::Coinbase, SourceRef::new("S")),
}),
EventPayload::VoidDecisionEvent(VoidDecisionEvent {
target_event_id: EventId::import(Source::Coinbase, SourceRef::new("R")),
}),
EventPayload::ClassifyRaw(ClassifyRaw {
target: EventId::import(Source::Coinbase, SourceRef::new("Q")),
as_: Box::new(EventPayload::Income(Income {
sat: 100_000,
usd_fmv: Some(dec!(65.00)),
fmv_status: FmvStatus::ManualEntry,
kind: IncomeKind::Mining,
business: true,
})),
}),
EventPayload::MethodElection(MethodElection {
effective_from: time::Date::from_calendar_date(2025, time::Month::June, 1).unwrap(),
method: crate::LotMethod::Hifo,
wallet: None,
}),
EventPayload::LotSelection(LotSelection {
disposal_event: EventId::import(Source::Coinbase, SourceRef::new("P")),
lots: vec![
LotPick {
lot: LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("O")),
split_sequence: 0,
},
sat: 75_000,
},
LotPick {
lot: LotId {
origin_event_id: EventId::decision(7),
split_sequence: 3,
},
sat: 25_000,
},
],
}),
EventPayload::ReclassifyIncome(ReclassifyIncome {
income_event: EventId::import(Source::River, SourceRef::new("in|river-income-001")),
business: true,
kind: Some(IncomeKind::Mining),
}),
EventPayload::ReclassifyIncome(ReclassifyIncome {
income_event: EventId::import(Source::River, SourceRef::new("in|river-income-002")),
business: true,
kind: None,
}),
EventPayload::SelfTransferPassthrough(SelfTransferPassthrough {
in_event: EventId::import(Source::Coinbase, SourceRef::new("in|cb-in-001")),
out_event: EventId::import(Source::River, SourceRef::new("out|river-out-001")),
}),
];
for p in payloads {
let ev = sample(p);
let json = serde_json::to_string(&ev).unwrap();
let back: LedgerEvent = serde_json::from_str(&json).unwrap();
assert_eq!(ev, back);
}
}
#[test]
fn method_election_decision_has_no_fingerprint() {
let me = EventPayload::MethodElection(MethodElection {
effective_from: time::Date::from_calendar_date(2025, time::Month::June, 1).unwrap(),
method: crate::LotMethod::Lifo,
wallet: None,
});
assert!(crate::persistence::fingerprint(&me).is_none());
}
#[test]
fn serde_backcompat_old_methodelection_loads_as_global() {
let old_json = r#"{"effective_from":[2025,152],"method":"Hifo"}"#;
assert!(
!old_json.contains("wallet"),
"fixture must model pre-scope on-disk JSON (no wallet field)"
);
let back: MethodElection = serde_json::from_str(old_json).unwrap();
assert_eq!(
back,
MethodElection {
effective_from: time::Date::from_calendar_date(2025, time::Month::June, 1).unwrap(),
method: crate::LotMethod::Hifo,
wallet: None, }
);
let scoped = MethodElection {
effective_from: time::Date::from_calendar_date(2025, time::Month::June, 1).unwrap(),
method: crate::LotMethod::Lifo,
wallet: Some(crate::identity::WalletId::Exchange {
provider: "coinbase".into(),
account: "main".into(),
}),
};
let round: MethodElection =
serde_json::from_str(&serde_json::to_string(&scoped).unwrap()).unwrap();
assert_eq!(round, scoped);
}
#[test]
fn reclassify_income_decision_has_no_fingerprint() {
let ri = EventPayload::ReclassifyIncome(ReclassifyIncome {
income_event: EventId::import(Source::Coinbase, SourceRef::new("X")),
business: true,
kind: Some(IncomeKind::Mining),
});
assert!(crate::persistence::fingerprint(&ri).is_none());
let ri_no_kind = EventPayload::ReclassifyIncome(ReclassifyIncome {
income_event: EventId::import(Source::Coinbase, SourceRef::new("Y")),
business: false,
kind: None,
});
assert!(crate::persistence::fingerprint(&ri_no_kind).is_none());
}
#[test]
fn self_transfer_passthrough_decision_has_no_fingerprint() {
let stp = EventPayload::SelfTransferPassthrough(SelfTransferPassthrough {
in_event: EventId::import(Source::Coinbase, SourceRef::new("IN")),
out_event: EventId::import(Source::Coinbase, SourceRef::new("OUT")),
});
assert!(crate::persistence::fingerprint(&stp).is_none());
}
#[test]
fn lot_selection_decision_has_no_fingerprint() {
let ls = EventPayload::LotSelection(LotSelection {
disposal_event: EventId::import(Source::Coinbase, SourceRef::new("P")),
lots: vec![LotPick {
lot: LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("O")),
split_sequence: 0,
},
sat: 100_000,
}],
});
assert!(crate::persistence::fingerprint(&ls).is_none());
}
#[test]
fn lot_pick_round_trips() {
let pick = LotPick {
lot: LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("L")),
split_sequence: 2,
},
sat: 123_456,
};
let json = serde_json::to_string(&pick).unwrap();
let back: LotPick = serde_json::from_str(&json).unwrap();
assert_eq!(pick, back);
}
#[test]
fn decimal_is_serialized_losslessly_as_string() {
let ev = sample(EventPayload::Acquire(Acquire {
sat: 1,
usd_cost: dec!(0.10),
fee_usd: dec!(0),
basis_source: BasisSource::ComputedFromCost,
}));
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains("\"0.10\"")); }
#[test]
fn reclassify_outflow_legacy_json_back_compat_donee_defaults_to_none() {
let gift_json = r#"{
"ReclassifyOutflow": {
"transfer_out_event": {"Import": {"source": "Coinbase", "source_ref": "OUT-1"}},
"as_": "GiftOut",
"principal_proceeds_or_fmv": "25000.00",
"fee_usd": null
}
}"#;
let parsed: EventPayload =
serde_json::from_str(gift_json).expect("legacy GiftOut JSON must deserialize");
match parsed {
EventPayload::ReclassifyOutflow(ro) => {
assert!(
matches!(ro.as_, OutflowClass::GiftOut),
"as_ must be GiftOut"
);
assert_eq!(
ro.donee, None,
"donee must be None for legacy records without the field"
);
}
other => panic!("expected ReclassifyOutflow, got {other:?}"),
}
let donate_json = r#"{
"ReclassifyOutflow": {
"transfer_out_event": {"Import": {"source": "Coinbase", "source_ref": "OUT-2"}},
"as_": {"Donate": {"appraisal_required": true}},
"principal_proceeds_or_fmv": "60000.00",
"fee_usd": null
}
}"#;
let parsed: EventPayload =
serde_json::from_str(donate_json).expect("legacy Donate JSON must deserialize");
match parsed {
EventPayload::ReclassifyOutflow(ro) => {
assert!(
matches!(ro.as_, OutflowClass::Donate { .. }),
"as_ must be Donate"
);
assert_eq!(
ro.donee, None,
"donee must be None for legacy Donate records without the field"
);
}
other => panic!("expected ReclassifyOutflow, got {other:?}"),
}
}
#[test]
fn ledger_event_round_trips_with_non_standard_utc_offset() {
let mut ev = sample(EventPayload::Acquire(Acquire {
sat: 75_000,
usd_cost: dec!(50.00),
fee_usd: dec!(0.25),
basis_source: BasisSource::FmvAtIncome,
}));
ev.original_tz = offset!(+05:45);
let json = serde_json::to_string(&ev).unwrap();
let back: LedgerEvent = serde_json::from_str(&json).unwrap();
assert_eq!(ev, back);
assert_eq!(back.original_tz, offset!(+05:45));
}
}