From 0e475e92cc54b89cb083591ad3cc2587bcf04df0 Mon Sep 17 00:00:00 2001 From: rgrant Date: Sat, 11 Apr 2026 19:13:37 +0000 Subject: [PATCH 1/3] 20260411 support Puerto Rico F1 Part III statement 24 :: capital gains --- src/main.rs | 41 ++++++++- src/model/gains.rs | 184 +++++++++++++++++++++++++++++++++++++ src/model/gains/tests.rs | 50 ++++++++++ src/model/kraken_amount.rs | 5 + 4 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/model/gains/tests.rs diff --git a/src/main.rs b/src/main.rs index 22496f4..bd2bb55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +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::{ + constants, CapGainsWorksheet, ExchangeRates, GainConfig, PrStatement24, State, Stats, + WorksheetName, +}; use taxcount::model::{exchange::Balances, ledgers::parsed::LedgerParsed}; use taxcount::util::{fifo::FIFO, year_ext::CheckYearsExt as _}; use taxcount::{bdk::bitcoin::Network, gitver_hashes}; @@ -494,6 +497,8 @@ fn run(args: Result) -> Result<(), Error> { gitver_hashes::print_all(); } + let mut pr_statement24_rows: Vec = Vec::new(); + for (worksheet_name, events) in worksheets.into_iter() { let underline = "=".repeat(worksheet_name.len()); println!("Worksheet {worksheet_name}"); @@ -662,7 +667,41 @@ fn run(args: Result) -> 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(); diff --git a/src/model/gains.rs b/src/model/gains.rs index ff2fe4a..3467ee1 100644 --- a/src/model/gains.rs +++ b/src/model/gains.rs @@ -4,6 +4,10 @@ use crate::model::events::{GainPortion, GainTerm}; use crate::model::kraken_amount::UsdAmount; use chrono::{DateTime, Utc}; use std::fmt::Display; +use std::rc::Rc; + +#[cfg(test)] +mod tests; #[derive(Debug)] pub struct CapGainsWorksheet { @@ -605,6 +609,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 @@ -901,3 +942,146 @@ impl GainMatrixLong { (gains, carryover) } } + +/// Newtype for worksheet names used in PR Statement-24. +#[derive(Clone, Debug)] +pub struct WorksheetName(String); + +impl From 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>, + pub lt_latest_sold: Option>, + pub st_earliest_acquired: Option>, + pub st_latest_sold: Option>, +} + +/// One row of PR Statement-24 (F1 Part III statement 24 :: capital gains). +#[derive(Debug)] +struct PrStatement24Row { + description: Rc, + date_acquired: DateTime, + date_sold: DateTime, + 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, +} + +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: Rc::from(format!("investment assets {worksheet_name} LT").as_str()), + 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: Rc::from(format!("investment assets {worksheet_name} ST").as_str()), + 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, + 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""# + )?; + + for row in &self.rows { + writeln!( + f, + r#""{description}","{date_acquired}","{date_sold}","{sale_price}","{market_value}","{adjusted_basis}","{gain_or_loss}","{us_gain}","{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(()) + } +} diff --git a/src/model/gains/tests.rs b/src/model/gains/tests.rs new file mode 100644 index 0000000..95a9faa --- /dev/null +++ b/src/model/gains/tests.rs @@ -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); +} diff --git a/src/model/kraken_amount.rs b/src/model/kraken_amount.rs index c0bb600..ed2db8b 100644 --- a/src/model/kraken_amount.rs +++ b/src/model/kraken_amount.rs @@ -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. From bff8459a4b4eb45ef80d45aca3e2e358e98f4550 Mon Sep 17 00:00:00 2001 From: rgrant Date: Sat, 11 Apr 2026 19:24:23 +0000 Subject: [PATCH 2/3] 20260411 remove spurious Rc --- src/model/gains.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/model/gains.rs b/src/model/gains.rs index 3467ee1..368c68d 100644 --- a/src/model/gains.rs +++ b/src/model/gains.rs @@ -4,7 +4,6 @@ use crate::model::events::{GainPortion, GainTerm}; use crate::model::kraken_amount::UsdAmount; use chrono::{DateTime, Utc}; use std::fmt::Display; -use std::rc::Rc; #[cfg(test)] mod tests; @@ -971,7 +970,7 @@ pub struct PrStatement24Dates { /// One row of PR Statement-24 (F1 Part III statement 24 :: capital gains). #[derive(Debug)] struct PrStatement24Row { - description: Rc, + description: String, date_acquired: DateTime, date_sold: DateTime, sale_price: UsdAmount, @@ -1018,7 +1017,7 @@ impl PrStatement24 { let adjusted_basis = sale_price - gain_or_loss; rows.push(PrStatement24Row { - description: Rc::from(format!("investment assets {worksheet_name} LT").as_str()), + description: format!("investment assets {worksheet_name} LT"), date_acquired, date_sold, sale_price, @@ -1043,7 +1042,7 @@ impl PrStatement24 { let adjusted_basis = sale_price - gain_or_loss; rows.push(PrStatement24Row { - description: Rc::from(format!("investment assets {worksheet_name} ST").as_str()), + description: format!("investment assets {worksheet_name} ST"), date_acquired, date_sold, sale_price, From 43b92dfa0d1dae88cd959857e03f95f8310b770f Mon Sep 17 00:00:00 2001 From: rgrant Date: Wed, 15 Apr 2026 22:34:12 +0000 Subject: [PATCH 3/3] minor formatting --- src/main.rs | 5 ++--- src/model/gains.rs | 12 ++++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index bd2bb55..ed565ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,11 +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, exchange::Balances, ledgers::parsed::LedgerParsed}; use taxcount::model::{ - constants, CapGainsWorksheet, ExchangeRates, GainConfig, PrStatement24, State, Stats, - WorksheetName, + CapGainsWorksheet, ExchangeRates, GainConfig, PrStatement24, State, Stats, WorksheetName, }; -use taxcount::model::{exchange::Balances, ledgers::parsed::LedgerParsed}; use taxcount::util::{fifo::FIFO, year_ext::CheckYearsExt as _}; use taxcount::{bdk::bitcoin::Network, gitver_hashes}; use thiserror::Error; diff --git a/src/model/gains.rs b/src/model/gains.rs index 368c68d..17ab89a 100644 --- a/src/model/gains.rs +++ b/src/model/gains.rs @@ -1062,13 +1062,21 @@ impl Display for PrStatement24 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!( f, - 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""# + 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, - r#""{description}","{date_acquired}","{date_sold}","{sale_price}","{market_value}","{adjusted_basis}","{gain_or_loss}","{us_gain}","{pr_gain}""#, + 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"),