Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ use taxcount::client::{bitcoind::BitcoindClient, esplora::EsploraClient, Client}
use taxcount::errors::{BitcoindClientError, EsploraClientError};
use taxcount::imports::kraken::{read_basis_lookup, read_ledgers, read_trades};
use taxcount::imports::wallet::{self, electrum, ledgerlive};
use taxcount::model::{constants, CapGainsWorksheet, ExchangeRates, GainConfig, State, Stats};
use taxcount::model::{exchange::Balances, ledgers::parsed::LedgerParsed};
use taxcount::model::{constants, exchange::Balances, ledgers::parsed::LedgerParsed};
use taxcount::model::{
CapGainsWorksheet, ExchangeRates, GainConfig, PrStatement24, State, Stats, WorksheetName,
};
use taxcount::util::{fifo::FIFO, year_ext::CheckYearsExt as _};
use taxcount::{bdk::bitcoin::Network, gitver_hashes};
use thiserror::Error;
Expand Down Expand Up @@ -494,6 +496,8 @@ fn run(args: Result<Args, CliError>) -> Result<(), Error> {
gitver_hashes::print_all();
}

let mut pr_statement24_rows: Vec<PrStatement24> = Vec::new();

for (worksheet_name, events) in worksheets.into_iter() {
let underline = "=".repeat(worksheet_name.len());
println!("Worksheet {worksheet_name}");
Expand Down Expand Up @@ -662,7 +666,41 @@ fn run(args: Result<Args, CliError>) -> Result<(), Error> {
}

sums.assert_error_check();

if bona_fide_residency.is_some() {
let dates = worksheet.pr_statement24_dates();
let name = WorksheetName::from(worksheet_name.to_string());
let statement = PrStatement24::from_worksheet(&name, &sums, &dates);
pr_statement24_rows.push(statement);
}
}

if bona_fide_residency.is_some() && !pr_statement24_rows.is_empty() {
let mut all_rows = PrStatement24::empty();
for statement in pr_statement24_rows {
all_rows.extend(statement);
}

if let Some(path) = args.worksheet_path.as_ref().map(|root| {
let filename = format!("{}pr-statement24.csv", args.worksheet_prefix);
root.join(filename)
}) {
std::fs::write(&path, all_rows.to_string())?;

let path = path.display();
let underline = "=".repeat(path.to_string().len());
println!("PR Statement-24 written to {path}");
println!("== ============ ======= == {underline}");
println!();
} else {
println!("PR Statement-24");
println!("== ============");
println!();
println!("{all_rows}");
println!();
}
}

check_pending(&state);
stats.pretty_print();

Expand Down
191 changes: 191 additions & 0 deletions src/model/gains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ use crate::model::kraken_amount::UsdAmount;
use chrono::{DateTime, Utc};
use std::fmt::Display;

#[cfg(test)]
mod tests;

#[derive(Debug)]
pub struct CapGainsWorksheet {
worksheet: Vec<CapGainsWorksheetRow>,
Expand Down Expand Up @@ -605,6 +608,43 @@ impl CapGainsWorksheet {
}
}

pub fn pr_statement24_dates(&self) -> PrStatement24Dates {
let mut dates = PrStatement24Dates::default();

for row in &self.worksheet {
for detail in &row.trade_details {
let (is_long, basis_date) = match &detail.net_gain {
GainTerm::LongUs(p) | GainTerm::LongBonaFide(p) => (true, p.basis_date),
GainTerm::Long { us, .. } => (true, us.basis_date),
GainTerm::ShortUs(p) | GainTerm::ShortBonaFide(p) => (false, p.basis_date),
GainTerm::Short { us, .. } => (false, us.basis_date),
};

if is_long {
dates.lt_earliest_acquired = Some(match dates.lt_earliest_acquired {
Some(existing) => existing.min(basis_date),
None => basis_date,
});
dates.lt_latest_sold = Some(match dates.lt_latest_sold {
Some(existing) => existing.max(row.event_date),
None => row.event_date,
});
} else {
dates.st_earliest_acquired = Some(match dates.st_earliest_acquired {
Some(existing) => existing.min(basis_date),
None => basis_date,
});
dates.st_latest_sold = Some(match dates.st_latest_sold {
Some(existing) => existing.max(row.event_date),
None => row.event_date,
});
}
}
}

dates
}

pub fn sums(&self) -> Sums {
let ledger_proceeds = self
.worksheet
Expand Down Expand Up @@ -901,3 +941,154 @@ impl GainMatrixLong {
(gains, carryover)
}
}

/// Newtype for worksheet names used in PR Statement-24.
#[derive(Clone, Debug)]
pub struct WorksheetName(String);

impl From<String> for WorksheetName {
fn from(name: String) -> Self {
Self(name)
}
}

impl Display for WorksheetName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}

/// Collected trade dates per (worksheet, LT/ST) for PR Statement-24.
#[derive(Debug, Default)]
pub struct PrStatement24Dates {
pub lt_earliest_acquired: Option<DateTime<Utc>>,
pub lt_latest_sold: Option<DateTime<Utc>>,
pub st_earliest_acquired: Option<DateTime<Utc>>,
pub st_latest_sold: Option<DateTime<Utc>>,
}

/// One row of PR Statement-24 (F1 Part III statement 24 :: capital gains).
#[derive(Debug)]
struct PrStatement24Row {
description: String,
date_acquired: DateTime<Utc>,
date_sold: DateTime<Utc>,
sale_price: UsdAmount,
market_value: UsdAmount,
adjusted_basis: UsdAmount,
gain_or_loss: UsdAmount,
us_gain: UsdAmount,
pr_gain: UsdAmount,
}

/// PR Statement-24 report containing rows for each (worksheet, LT/ST) with bona fide data.
#[derive(Debug)]
pub struct PrStatement24 {
rows: Vec<PrStatement24Row>,
}

impl PrStatement24 {
pub fn empty() -> Self {
Self { rows: Vec::new() }
}

pub fn extend(&mut self, other: Self) {
self.rows.extend(other.rows);
}

/// Build Statement-24 rows from a single worksheet's Sums and collected dates.
pub fn from_worksheet(
worksheet_name: &WorksheetName,
sums: &Sums,
dates: &PrStatement24Dates,
) -> Self {
let mut rows = Vec::new();

if let (Some(pr_gain), Some(bona_fide_long), Some(date_acquired), Some(date_sold)) = (
sums.gains_bona_fide_long,
&sums.gain_matrix.bona_fide_long,
dates.lt_earliest_acquired,
dates.lt_latest_sold,
) {
let us_gain = sums.gains_us_long;
let sale_price = bona_fide_long.trade_proceeds;
let gain_or_loss = us_gain + pr_gain;
let market_value = sale_price - pr_gain;
let adjusted_basis = sale_price - gain_or_loss;

rows.push(PrStatement24Row {
description: format!("investment assets {worksheet_name} LT"),
date_acquired,
date_sold,
sale_price,
market_value,
adjusted_basis,
gain_or_loss,
us_gain,
pr_gain,
});
}

if let (Some(pr_gain), Some(bona_fide_short), Some(date_acquired), Some(date_sold)) = (
sums.gains_bona_fide_short,
&sums.gain_matrix.bona_fide_short,
dates.st_earliest_acquired,
dates.st_latest_sold,
) {
let us_gain = sums.gains_us_short;
let sale_price = bona_fide_short.trade_proceeds;
let gain_or_loss = us_gain + pr_gain;
let market_value = sale_price - pr_gain;
let adjusted_basis = sale_price - gain_or_loss;

rows.push(PrStatement24Row {
description: format!("investment assets {worksheet_name} ST"),
date_acquired,
date_sold,
sale_price,
market_value,
adjusted_basis,
gain_or_loss,
us_gain,
pr_gain,
});
}

Self { rows }
}
}

impl Display for PrStatement24 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
concat!(
r#""Property Description","Date Acquired","Date Sold","A: Sale Price","#,
r#""B: Market Value","C: Adjusted Basis","D: Gain or Loss","E: US-Sourced Gain","#,
r#""F: PR-Sourced Gain""#,
),
)?;

for row in &self.rows {
writeln!(
f,
concat!(
r#""{description}","{date_acquired}","{date_sold}","{sale_price}","#,
r#""{market_value}","{adjusted_basis}","{gain_or_loss}","{us_gain}","#,
r#""{pr_gain}""#,
),
description = row.description,
date_acquired = row.date_acquired.format("%F"),
date_sold = row.date_sold.format("%F"),
sale_price = row.sale_price,
market_value = row.market_value,
adjusted_basis = row.adjusted_basis,
gain_or_loss = row.gain_or_loss,
us_gain = row.us_gain,
pr_gain = row.pr_gain,
)?;
}

Ok(())
}
}
50 changes: 50 additions & 0 deletions src/model/gains/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use super::*;
use chrono::TimeZone;

fn usd(n: i64) -> UsdAmount {
UsdAmount::from_int(n)
}

#[test]
fn pr_statement24_from_sums() {
let sums = Sums {
ledger_proceeds: usd(0),
gain_matrix: GainMatrix {
income: usd(0),
us_short: GainMatrixShort::default(),
us_long: GainMatrixLong::default(),
bona_fide_short: Some(GainMatrixShort {
trade_proceeds: usd(500),
..Default::default()
}),
bona_fide_long: Some(GainMatrixLong {
trade_proceeds: usd(1000),
..Default::default()
}),
},
gains_us_short: usd(50),
gains_us_long: usd(200),
gains_bona_fide_short: Some(usd(100)),
gains_bona_fide_long: Some(usd(300)),
};

let dates = PrStatement24Dates {
lt_earliest_acquired: Some(Utc.with_ymd_and_hms(2020, 1, 15, 0, 0, 0).unwrap()),
lt_latest_sold: Some(Utc.with_ymd_and_hms(2024, 12, 15, 0, 0, 0).unwrap()),
st_earliest_acquired: Some(Utc.with_ymd_and_hms(2024, 3, 1, 0, 0, 0).unwrap()),
st_latest_sold: Some(Utc.with_ymd_and_hms(2024, 11, 20, 0, 0, 0).unwrap()),
};

let worksheet_name = WorksheetName::from("test-exchange".to_string());
let statement = PrStatement24::from_worksheet(&worksheet_name, &sums, &dates);

let expected = concat!(
r#""Property Description","Date Acquired","Date Sold","A: Sale Price","B: Market Value","C: Adjusted Basis","D: Gain or Loss","E: US-Sourced Gain","F: PR-Sourced Gain""#,
"\n",
r#""investment assets test-exchange LT","2020-01-15","2024-12-15","1000.0000","700.0000","500.0000","500.0000","200.0000","300.0000""#,
"\n",
r#""investment assets test-exchange ST","2024-03-01","2024-11-20","500.0000","400.0000","350.0000","150.0000","50.0000","100.0000""#,
"\n",
);
assert_eq!(statement.to_string(), expected);
}
5 changes: 5 additions & 0 deletions src/model/kraken_amount.rs
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,11 @@ impl UsdAmount {
Self(FiatAmount(self.0 .0.max(other.0 .0)))
}

#[cfg(test)]
pub(crate) fn from_int(n: i64) -> Self {
Self(FiatAmount(Decimal::from(n)))
}

/// A typed division between differing units.
pub(crate) fn sub_divide(self, other: KrakenAmount) -> Self {
// TODO: Rescale the `Decimal` value after division.
Expand Down
Loading