use crate::AdapterError;
use btctax_core::{Sat, Usd};
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
use std::str::FromStr;
use time::format_description::well_known::Rfc3339;
use time::macros::{datetime, format_description};
use time::{Date, Duration, OffsetDateTime, PrimitiveDateTime, UtcOffset};
pub const SATS_PER_BTC: i64 = 100_000_000;
pub const BTC_DP: u32 = 8;
pub const USD_DP: u32 = 2;
pub fn parse_usd(
source: &'static str,
line: usize,
field: &'static str,
raw: &str,
) -> Result<Usd, AdapterError> {
let t = raw.trim();
let (neg, body) = match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
Some(inner) => (true, inner),
None => (false, t),
};
let cleaned: String = body
.chars()
.filter(|c| !matches!(c, '$' | ',' | ' ' | '\u{a0}'))
.collect();
if cleaned.is_empty() {
return Ok(Decimal::ZERO);
}
let mut d = Decimal::from_str(&cleaned).map_err(|e| AdapterError::Parse {
adapter: source,
line,
field,
value: raw.to_string(),
reason: e.to_string(),
})?;
if neg {
d.set_sign_negative(true);
}
Ok(d)
}
pub fn parse_btc_to_sat(
source: &'static str,
line: usize,
field: &'static str,
raw: &str,
) -> Result<Sat, AdapterError> {
let t = raw.trim();
let cleaned: String = t
.chars()
.filter(|c| !matches!(c, ',' | ' ' | '\u{a0}' | '\u{20bf}'))
.collect();
let body = cleaned
.strip_suffix("BTC")
.or_else(|| cleaned.strip_suffix("btc"))
.unwrap_or(&cleaned)
.trim();
if body.is_empty() {
return Ok(0);
}
let btc = Decimal::from_str(body).map_err(|e| AdapterError::Parse {
adapter: source,
line,
field,
value: raw.to_string(),
reason: e.to_string(),
})?;
let sats = (btc * Decimal::from(SATS_PER_BTC)).round();
sats.to_i64().ok_or_else(|| AdapterError::Parse {
adapter: source,
line,
field,
value: raw.to_string(),
reason: "satoshi value out of i64 range".to_string(),
})
}
pub fn parse_timestamp(
source: &'static str,
line: usize,
raw: &str,
) -> Result<(OffsetDateTime, UtcOffset), AdapterError> {
let t = raw.trim();
if let Ok(odt) = OffsetDateTime::parse(t, &Rfc3339) {
return Ok((odt.to_offset(UtcOffset::UTC), odt.offset()));
}
let dt_fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
if let Some(stripped) = t.strip_suffix(" UTC").or_else(|| t.strip_suffix(" utc")) {
if let Ok(pdt) = PrimitiveDateTime::parse(stripped.trim(), &dt_fmt) {
return Ok((pdt.assume_utc(), UtcOffset::UTC));
}
}
if let Some(idx) = t.find(' ') {
let candidate = fix_short_offset(&format!("{}T{}", &t[..idx], &t[idx + 1..]));
if let Ok(odt) = OffsetDateTime::parse(&candidate, &Rfc3339) {
return Ok((odt.to_offset(UtcOffset::UTC), odt.offset()));
}
}
let us_fmt = format_description!("[month]/[day]/[year] [hour]:[minute]:[second]");
if let Ok(pdt) = PrimitiveDateTime::parse(t, &us_fmt) {
return Ok((pdt.assume_utc(), UtcOffset::UTC));
}
if let Ok(pdt) = PrimitiveDateTime::parse(t, &dt_fmt) {
return Ok((pdt.assume_utc(), UtcOffset::UTC));
}
let date_fmt = format_description!("[year]-[month]-[day]");
if let Ok(d) = Date::parse(t, &date_fmt) {
return Ok((d.midnight().assume_utc(), UtcOffset::UTC));
}
Err(AdapterError::Parse {
adapter: source,
line,
field: "timestamp",
value: raw.to_string(),
reason: "unrecognized timestamp format".to_string(),
})
}
fn fix_short_offset(s: &str) -> String {
match s.rfind(['+', '-']).filter(|&p| p > 10) {
Some(pos) => {
let (head, off) = s.split_at(pos);
let (sign, digits) = off.split_at(1);
let norm = match digits.len() {
2 => format!("{sign}{digits}:00"),
4 => format!("{sign}{}:{}", &digits[..2], &digits[2..]),
_ => off.to_string(),
};
format!("{head}{norm}")
}
None => s.to_string(),
}
}
pub fn excel_serial_to_utc(serial: f64) -> OffsetDateTime {
let epoch = datetime!(1899-12-30 00:00:00 UTC);
let whole = serial.trunc() as i64;
let secs = (serial.fract() * 86_400.0).round() as i64;
epoch + Duration::days(whole) + Duration::seconds(secs)
}
pub fn parse_timestamp_flex(
source: &'static str,
line: usize,
raw: &str,
) -> Result<(OffsetDateTime, UtcOffset), AdapterError> {
match parse_timestamp(source, line, raw) {
Ok(r) => Ok(r),
Err(e) => match raw.trim().parse::<f64>() {
Ok(serial) => Ok((excel_serial_to_utc(serial), UtcOffset::UTC)),
Err(_) => Err(e),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use time::macros::{datetime, offset};
#[test]
fn parses_usd_exactly_no_float() {
assert_eq!(parse_usd("t", 1, "f", "1234.56").unwrap(), dec!(1234.56));
assert_eq!(parse_usd("t", 1, "f", "$1,234.56").unwrap(), dec!(1234.56));
assert_eq!(parse_usd("t", 1, "f", " 0.10 ").unwrap(), dec!(0.10));
assert_eq!(parse_usd("t", 1, "f", "(2.50)").unwrap(), dec!(-2.50)); assert_eq!(parse_usd("t", 1, "f", "").unwrap(), dec!(0));
}
#[test]
fn btc_to_sat_is_exact_integer() {
assert_eq!(parse_btc_to_sat("t", 1, "f", "1").unwrap(), 100_000_000);
assert_eq!(parse_btc_to_sat("t", 1, "f", "0.00000001").unwrap(), 1);
assert_eq!(
parse_btc_to_sat("t", 1, "f", "0.12345678 BTC").unwrap(),
12_345_678
);
assert_eq!(parse_btc_to_sat("t", 1, "f", "-0.5").unwrap(), -50_000_000);
}
#[test]
fn subsatoshi_btc_rounds_to_nearest_satoshi() {
assert_eq!(
parse_btc_to_sat("gemini", 1, "f", "0.0010216163").unwrap(),
102_162
); assert_eq!(
parse_btc_to_sat("gemini", 1, "f", "0.0997506234").unwrap(),
9_975_062
); assert_eq!(
parse_btc_to_sat("gemini", 1, "f", "0.7674706206").unwrap(),
76_747_062
); assert_eq!(
parse_btc_to_sat("gemini", 1, "f", "-0.1156442018").unwrap(),
-11_564_420
); assert_eq!(
parse_btc_to_sat("gemini", 1, "f", "0.00076035204").unwrap(),
76_035
); assert_eq!(
parse_btc_to_sat("river", 7, "amount", "0.000000001").unwrap(),
0
); assert_eq!(parse_btc_to_sat("t", 1, "f", "0.000000005").unwrap(), 0); assert_eq!(parse_btc_to_sat("t", 1, "f", "0.000000025").unwrap(), 2); }
#[test]
fn timestamp_rfc3339_keeps_offset_then_normalizes_to_utc() {
let (utc, tz) = parse_timestamp("t", 1, "2025-03-01T20:30:00-05:00").unwrap();
assert_eq!(utc, datetime!(2025-03-02 01:30:00 UTC));
assert_eq!(tz, offset!(-05:00));
}
#[test]
fn timestamp_naive_assumed_utc() {
let (utc, tz) = parse_timestamp("t", 1, "2025-03-01 12:00:00").unwrap();
assert_eq!(utc, datetime!(2025-03-01 12:00:00 UTC));
assert_eq!(tz, offset!(+00:00));
let (utc2, _) = parse_timestamp("t", 1, "2025-03-01").unwrap();
assert_eq!(utc2, datetime!(2025-03-01 00:00:00 UTC));
}
#[test]
fn timestamp_confirmed_export_formats() {
let (utc, tz) = parse_timestamp("coinbase", 1, "2025-03-01 12:00:00 UTC").unwrap();
assert_eq!(
(utc, tz),
(datetime!(2025-03-01 12:00:00 UTC), offset!(+00:00))
);
let (utc, tz) = parse_timestamp("swan", 1, "2025-03-02 09:00:00+00").unwrap();
assert_eq!(
(utc, tz),
(datetime!(2025-03-02 09:00:00 UTC), offset!(+00:00))
);
let (utc, tz) = parse_timestamp("swan", 1, "03/01/2025 12:00:00").unwrap();
assert_eq!(
(utc, tz),
(datetime!(2025-03-01 12:00:00 UTC), offset!(+00:00))
);
}
#[test]
fn excel_serial_and_flex_parse() {
assert_eq!(
excel_serial_to_utc(25569.0),
datetime!(1970-01-01 00:00:00 UTC)
);
assert_eq!(
excel_serial_to_utc(25569.5),
datetime!(1970-01-01 12:00:00 UTC)
);
let (utc, tz) = parse_timestamp_flex("gemini", 1, "25569.5").unwrap();
assert_eq!(
(utc, tz),
(datetime!(1970-01-01 12:00:00 UTC), offset!(+00:00))
);
let (utc, _) = parse_timestamp_flex("gemini", 1, "2025-03-01 12:00:00").unwrap();
assert_eq!(utc, datetime!(2025-03-01 12:00:00 UTC));
}
}