use rust_decimal::{Decimal, RoundingStrategy};
use time::{Date, Month, OffsetDateTime, UtcOffset};
pub type Sat = i64;
pub type Usd = Decimal;
pub type TaxDate = Date;
pub const MONEY_ROUNDING: RoundingStrategy = RoundingStrategy::MidpointNearestEven;
pub const SATS_PER_BTC: i64 = 100_000_000;
pub const TRANSITION_DATE: TaxDate = time::macros::date!(2025 - 01 - 01);
pub const TY2025_RETURN_DUE: TaxDate = time::macros::date!(2026 - 04 - 15);
pub fn round_cents(v: Usd) -> Usd {
v.round_dp_with_strategy(2, MONEY_ROUNDING)
}
pub const DOLLAR_ROUNDING: RoundingStrategy = RoundingStrategy::MidpointAwayFromZero;
pub fn round_dollar(v: Usd) -> Usd {
v.round_dp_with_strategy(0, DOLLAR_ROUNDING)
}
pub fn split_pro_rata(total: Usd, part_sat: Sat, whole_sat: Sat) -> (Usd, Usd) {
if whole_sat <= 0 || part_sat <= 0 {
return (Usd::ZERO, total);
}
if part_sat >= whole_sat {
return (total, Usd::ZERO);
}
let (p, w) = (Usd::from(part_sat), Usd::from(whole_sat));
let part = total
.checked_mul(p)
.and_then(|x| x.checked_div(w))
.or_else(|| total.checked_div(w).and_then(|x| x.checked_mul(p)))
.map(round_cents)
.unwrap_or(Usd::ZERO); (part, total - part)
}
pub fn tax_date(utc: OffsetDateTime, tz: UtcOffset) -> TaxDate {
utc.to_offset(tz).date()
}
pub fn one_year_after(d: TaxDate) -> TaxDate {
let y = d.year() + 1;
Date::from_calendar_date(y, d.month(), d.day()).unwrap_or_else(|_| {
Date::from_calendar_date(y, d.month(), 28).expect("Feb 28 is always valid")
})
}
pub fn is_long_term(acquired: TaxDate, disposed: TaxDate) -> bool {
disposed > one_year_after(acquired)
}
pub fn long_term_default_acquired(date: TaxDate) -> TaxDate {
let prior_year = date.replace_year(date.year() - 1).unwrap_or_else(|_| {
Date::from_calendar_date(date.year() - 1, Month::February, 28)
.expect("Feb 28 is always valid")
});
prior_year.previous_day().unwrap_or(prior_year) }
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use time::macros::{date, datetime, offset};
#[test]
fn rounds_half_even_to_cents() {
assert_eq!(round_cents(dec!(1.005)), dec!(1.00)); assert_eq!(round_cents(dec!(1.015)), dec!(1.02)); assert_eq!(round_cents(dec!(2.675)), dec!(2.68));
assert_eq!(round_cents(dec!(1.234)), dec!(1.23)); }
#[test]
fn round_dollar_is_half_up_and_differs_from_half_even() {
assert_eq!(round_dollar(dec!(2.50)), dec!(3));
assert_eq!(round_dollar(dec!(1.39)), dec!(1));
assert_eq!(round_dollar(dec!(1162.50)), dec!(1163));
assert_eq!(round_dollar(dec!(302.50)), dec!(303));
assert_eq!(
dec!(1162.50).round_dp_with_strategy(0, MONEY_ROUNDING),
dec!(1162)
);
assert_eq!(
dec!(302.50).round_dp_with_strategy(0, MONEY_ROUNDING),
dec!(302)
);
assert_eq!(round_dollar(dec!(-2.50)), dec!(-3));
}
#[test]
fn round_dollar_cross_foots_printed_lines() {
let a = round_dollar(dec!(271.50)); let b = round_dollar(dec!(499.50)); assert_eq!(a + b, dec!(772)); assert_eq!(round_dollar(dec!(271.50) + dec!(499.50)), dec!(771)); assert_ne!(a + b, round_dollar(dec!(271.50) + dec!(499.50)));
}
#[test]
fn pro_rata_split_conserves_exactly() {
let (part, rest) = split_pro_rata(dec!(100.00), 333, 1000);
assert_eq!(part + rest, dec!(100.00));
assert_eq!(part, dec!(33.30)); }
#[test]
fn split_pro_rata_edges() {
let (part, rest) = split_pro_rata(dec!(10.00), 0, 1000);
assert_eq!(part, Usd::ZERO);
assert_eq!(rest, dec!(10.00));
assert_eq!(part + rest, dec!(10.00));
let (part, rest) = split_pro_rata(dec!(10.00), 500, 500);
assert_eq!(part, dec!(10.00));
assert_eq!(rest, Usd::ZERO);
assert_eq!(part + rest, dec!(10.00));
let (part, rest) = split_pro_rata(dec!(10.00), 600, 500);
assert_eq!(part, dec!(10.00));
assert_eq!(rest, Usd::ZERO);
assert_eq!(part + rest, dec!(10.00));
let (part, rest) = split_pro_rata(dec!(10.00), 100, 0);
assert_eq!(part, Usd::ZERO);
assert_eq!(rest, dec!(10.00));
assert_eq!(part + rest, dec!(10.00));
}
#[test]
fn tax_date_uses_original_tz_calendar_date() {
let utc = datetime!(2025-01-01 01:30:00 UTC);
assert_eq!(tax_date(utc, offset!(-05:00)), date!(2024 - 12 - 31));
assert_eq!(tax_date(utc, offset!(+00:00)), date!(2025 - 01 - 01));
}
#[test]
fn holding_period_boundary_tp4() {
let acq = date!(2020 - 06 - 19);
assert!(!is_long_term(acq, date!(2021 - 06 - 19)));
assert!(is_long_term(acq, date!(2021 - 06 - 20)));
assert!(!is_long_term(acq, acq)); }
#[test]
fn leap_day_anniversary_falls_back_to_feb_28() {
assert_eq!(one_year_after(date!(2020 - 02 - 29)), date!(2021 - 02 - 28));
}
}