use crate::adapter::{Adapter, FileGroup, GroupOutput, SourceFile};
use crate::normalize::{exchange_wallet, raw_of, Direction, SourceRefMint};
use crate::parse::{parse_btc_to_sat, parse_timestamp, parse_usd};
use crate::read::{peek_text, read_table, RawRow, ReadOpts, TableRole};
use crate::AdapterError;
use btctax_core::{
Acquire, BasisSource, Dispose, DisposeKind, EventId, EventPayload, LedgerEvent, PriceProvider,
Source, SourceRef, TransferIn, TransferOut, Unclassified, Usd,
};
const SRC: &str = "coinbase";
const ASSET_BTC: &str = "BTC";
mod cols {
pub const ID: &str = "ID";
pub const TIMESTAMP: &str = "Timestamp";
pub const TX_TYPE: &str = "Transaction Type";
pub const ASSET: &str = "Asset";
pub const QTY: &str = "Quantity Transacted";
pub const SUBTOTAL: &str = "Subtotal";
#[allow(dead_code)] pub const TOTAL: &str = "Total (inclusive of fees and/or spread)";
pub const FEES: &str = "Fees and/or Spread";
pub const SENDER_ADDR: &str = "Sender Address";
pub const RECIPIENT_ADDR: &str = "Recipient Address";
}
fn read_opts() -> ReadOpts {
ReadOpts {
header_signature: &[cols::ID, cols::TX_TYPE, cols::QTY],
..Default::default()
}
}
pub struct Coinbase;
impl Coinbase {
fn event(
&self,
id_ref: SourceRef,
utc: time::OffsetDateTime,
tz: time::UtcOffset,
payload: EventPayload,
) -> LedgerEvent {
LedgerEvent {
id: EventId::import(Source::Coinbase, id_ref),
utc_timestamp: utc,
original_tz: tz,
wallet: Some(exchange_wallet(Source::Coinbase)),
payload,
}
}
}
impl Adapter for Coinbase {
fn source(&self) -> Source {
Source::Coinbase
}
fn detect(&self, file: &SourceFile) -> Result<bool, AdapterError> {
if file
.path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("xlsx"))
.unwrap_or(false)
{
return Ok(false);
}
let snip = peek_text(&file.path, 4096)?;
Ok(snip.contains(cols::TX_TYPE)
&& snip.contains(cols::QTY)
&& snip.contains(cols::SUBTOTAL))
}
fn group(&self, files: Vec<SourceFile>) -> Vec<FileGroup> {
files
.into_iter()
.map(|f| FileGroup {
source: Source::Coinbase,
label: f.path.display().to_string(),
files: vec![f],
})
.collect()
}
fn parse(&self, group: &FileGroup) -> Result<Vec<RawRow>, AdapterError> {
let opts = read_opts();
let mut rows = Vec::new();
for f in &group.files {
rows.extend(read_table(&f.path, TableRole::Single, SRC, &opts)?);
}
Ok(rows)
}
fn normalize(
&self,
_group: &FileGroup,
rows: Vec<RawRow>,
_prices: &dyn PriceProvider,
) -> Result<GroupOutput, AdapterError> {
let mint = SourceRefMint::default();
let mut out = GroupOutput {
parsed_rows: rows.len(),
..Default::default()
};
for row in &rows {
let asset = row.opt(cols::ASSET).unwrap_or("");
if !asset.eq_ignore_ascii_case(ASSET_BTC) {
out.dropped_no_btc += 1;
continue;
}
let ttype = row.get(SRC, cols::TX_TYPE)?;
let sat = parse_btc_to_sat(
SRC,
row.line,
"Quantity Transacted",
row.get(SRC, cols::QTY)?,
)?
.abs();
let (utc, tz) = parse_timestamp(SRC, row.line, row.get(SRC, cols::TIMESTAMP)?)?;
let id = row.get(SRC, cols::ID)?;
let subtotal = row
.opt(cols::SUBTOTAL)
.map(|s| parse_usd(SRC, row.line, "Subtotal", s))
.transpose()?
.unwrap_or(Usd::ZERO);
let fees = match row.opt(cols::FEES) {
Some(s) => parse_usd(SRC, row.line, "Fees and/or Spread", s)?,
None => Usd::ZERO,
};
let sender = row.opt(cols::SENDER_ADDR).map(|s| s.to_string());
let recipient = row.opt(cols::RECIPIENT_ADDR).map(|s| s.to_string());
let (dir, payload): (Direction, EventPayload) =
match ttype.to_ascii_lowercase().as_str() {
"buy" => (
Direction::Trade,
EventPayload::Acquire(Acquire {
sat,
usd_cost: subtotal,
fee_usd: fees,
basis_source: BasisSource::ExchangeProvided,
}),
),
"sell" => (
Direction::Trade,
EventPayload::Dispose(Dispose {
sat,
usd_proceeds: subtotal,
fee_usd: fees,
kind: DisposeKind::Sell,
}),
),
"send" | "withdrawal" => (
Direction::Out,
EventPayload::TransferOut(TransferOut {
sat,
fee_sat: None,
dest_addr: recipient,
txid: None,
}),
),
"receive" => (
Direction::In,
EventPayload::TransferIn(TransferIn {
sat,
src_addr: sender,
txid: None,
}),
),
"exchange deposit" | "pro deposit" => {
out.unclassified += 1;
(
Direction::In,
EventPayload::Unclassified(Unclassified { raw: raw_of(row) }),
)
}
"exchange withdrawal" | "pro withdrawal" => {
out.unclassified += 1;
(
Direction::Out,
EventPayload::Unclassified(Unclassified { raw: raw_of(row) }),
)
}
"order" => {
out.unclassified += 1;
(
Direction::Trade,
EventPayload::Unclassified(Unclassified { raw: raw_of(row) }),
)
}
_ => {
out.unclassified += 1;
(
Direction::Trade,
EventPayload::Unclassified(Unclassified { raw: raw_of(row) }),
)
}
};
let id_ref = mint.native(dir, id);
out.events.push(self.event(id_ref, utc, tz, payload));
}
Ok(out)
}
}