From 4d12f0538231d9a8c19f368f275080c969cfd1c2 Mon Sep 17 00:00:00 2001 From: Mateusz Bronk Date: Tue, 10 Mar 2026 22:16:11 +0100 Subject: [PATCH 1/3] Fix PLN amount rounding for compliance with tax rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish tax law requires different rounding methods depending on income type, which was not previously implemented. This change brings the calculation into compliance with Art. 63 Ordynacji Podatkowej (OP). 1. Per currency-converted "tax event" (i.e. stock sales, dividend...), convert each result to full "grosz" (0,01 precision) - before summing. Basis: Mathematical rounding rules (0,01zł is the lowest monetary value) 2. Interests and dividends are separated as they follow different rounding rules when calculating the lump-sum tax a) Interests aggregate (art. 30a ust. 1 pkt 3 PIT) as well as their resulting lump-sum tax, are rounded UP to the nearest full "grosz" — art. 63 §1a OP b) Dividends aggregate (art. 30a ust. 1 pkt 4 PIT) as well as their resulting lump-sum tax, are rounded to the nearest full ZLOTY (0,50zl -> 1zl) — art. 63 §1 OP c) Foreign tax withholding: no rounding the standard FX rule to round to grosz (0,01) precision - rule #1 (above) d) Net/gross/cost stock proceeds are not subject to lump-sum tax calculations and reported in full on PIT-38 form, hence only standard FX rules (#1-above) applies and they are reported with "grosz" precision. Signed-off-by: Mateusz Bronk --- src/de.rs | 9 +++- src/gui.rs | 5 +- src/lib.rs | 132 +++++++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 41 +++++++++------- src/pl.rs | 109 ++++++++++++++++++++++++++++++++++++++----- src/us.rs | 6 ++- 6 files changed, 257 insertions(+), 45 deletions(-) diff --git a/src/de.rs b/src/de.rs index 2904a1a..1762d79 100644 --- a/src/de.rs +++ b/src/de.rs @@ -57,13 +57,18 @@ impl etradeTaxReturnHelper::Residency for DE { fn present_result( &self, + gross_interests: f32, gross_div: f32, tax_div: f32, gross_sold: f32, cost_sold: f32, ) -> (Vec, Option) { + let total_gross_div = gross_interests + gross_div; let mut presentation: Vec = vec![]; - presentation.push(format!("===> (DIVIDENDS) INCOME: {:.2} EUR", gross_div)); + presentation.push(format!( + "===> (DIVIDENDS) INCOME: {:.2} EUR", + total_gross_div + )); presentation.push(format!("===> (DIVIDENDS) TAX PAID: {:.2} EUR", tax_div)); presentation.push(format!("===> (SOLD STOCK) INCOME: {:.2} EUR", gross_sold)); presentation.push(format!( @@ -95,7 +100,7 @@ mod tests { "===> (SOLD STOCK) TAX DEDUCTIBLE COST: 10.00 EUR".to_string(), ]; - let (results, _) = rd.present_result(gross_div, tax_div, gross_sold, cost_sold); + let (results, _) = rd.present_result(0.0f32, gross_div, tax_div, gross_sold, cost_sold); results .iter() diff --git a/src/gui.rs b/src/gui.rs index 789aca9..dd5a29e 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -120,7 +120,8 @@ fn create_execute_documents( nbuffer.set_text("Running..."); let rd: Box = Box::new(PL {}); let etradeTaxReturnHelper::TaxCalculationResult { - gross_income: gross_div, + gross_interests, + gross_div, tax: tax_div, gross_sold, cost_sold, @@ -139,7 +140,7 @@ fn create_execute_documents( panic!("Error: unable to perform taxation"); } }; - let (presentation,warning) = rd.present_result(gross_div, tax_div, gross_sold, cost_sold); + let (presentation,warning) = rd.present_result(gross_interests, gross_div, tax_div, gross_sold, cost_sold); buffer.set_text(&presentation.join("\n")); if let Some(warn_msg) = warning { nbuffer.set_text(&warn_msg); diff --git a/src/lib.rs b/src/lib.rs index e5cdd9a..e1e419a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,7 +58,7 @@ pub enum Exchange { USD(String), } -#[derive(Debug, PartialEq, PartialOrd)] +#[derive(Debug, PartialEq, PartialOrd, Clone)] pub struct Transaction { pub transaction_date: String, pub gross: Currency, @@ -136,6 +136,7 @@ impl SoldTransaction { pub trait Residency { fn present_result( &self, + gross_interests: f32, gross_div: f32, tax_div: f32, gross_sold: f32, @@ -254,7 +255,12 @@ pub trait Residency { } pub struct TaxCalculationResult { - pub gross_income: f32, + /// Sum of all interest income (eTrade + Revolut savings) converted to PLN per-transaction. + /// Art. 30a ust. 1 pkt 1–3 PIT — rounding: Art. 63 §1a OP (ceil to grosz). + pub gross_interests: f32, + /// Sum of all dividend income (eTrade + Revolut stock divs) converted to PLN per-transaction. + /// Art. 30a ust. 1 pkt 4 PIT — rounding: Art. 63 §1 OP (half-up to full złoty). + pub gross_div: f32, pub tax: f32, pub gross_sold: f32, pub cost_sold: f32, @@ -287,30 +293,37 @@ fn create_client() -> reqwest::blocking::Client { client } +/// Rounds to 0.01 PLN (grosz). +fn round_to_grosz(val: f32) -> f32 { + (val * 100.0).round() / 100.0 +} + fn compute_div_taxation(transactions: &Vec) -> (f32, f32) { // Gross income from dividends in target currency (PLN, EUR etc.) + // Each transaction's FX-converted amount is rounded to 0.01 before summing. let gross_us_pl: f32 = transactions .iter() - .map(|x| x.exchange_rate * x.gross.value() as f32) + .map(|x| round_to_grosz(x.exchange_rate * x.gross.value() as f32)) .sum(); // Tax paid in US in PLN let tax_us_pl: f32 = transactions .iter() - .map(|x| x.exchange_rate * x.tax_paid.value() as f32) + .map(|x| round_to_grosz(x.exchange_rate * x.tax_paid.value() as f32)) .sum(); (gross_us_pl, tax_us_pl) } fn compute_sold_taxation(transactions: &Vec) -> (f32, f32) { // Net income from sold stock in target currency (PLN, EUR etc.) + // Each transaction's FX-converted amount is rounded to 0.01 before summing. let gross_us_pl: f32 = transactions .iter() - .map(|x| x.exchange_rate_settlement * x.income_us) + .map(|x| round_to_grosz(x.exchange_rate_settlement * x.income_us)) .sum(); // Cost of income e.g. cost_basis[target currency] let cost_us_pl: f32 = transactions .iter() - .map(|x| x.exchange_rate_acquisition * x.cost_basis) + .map(|x| round_to_grosz(x.exchange_rate_acquisition * x.cost_basis)) .sum(); (gross_us_pl, cost_us_pl) } @@ -519,14 +532,29 @@ pub fn run_taxation( println!("{}", per_company_report); } - let (gross_interests, _) = compute_div_taxation(&interests); - let (gross_div, tax_div) = compute_div_taxation(&transactions); + let (gross_etrade_interests, _) = compute_div_taxation(&interests); + let (gross_etrade_div, tax_etrade_div) = compute_div_taxation(&transactions); let (gross_sold, cost_sold) = compute_sold_taxation(&sold_transactions); - let (gross_revolut, tax_revolut) = compute_div_taxation(&revolut_dividends_transactions); + + // Split Revolut transactions: savings interests (company=None, art. 30a pkt 1-3) + // vs stock dividends (company=Some, art. 30a pkt 4). + let revolut_interests_txns: Vec = revolut_dividends_transactions + .iter() + .filter(|x| x.company.is_none()) + .cloned() + .collect(); + let revolut_div_txns: Vec = revolut_dividends_transactions + .iter() + .filter(|x| x.company.is_some()) + .cloned() + .collect(); + let (gross_revolut_interests, _) = compute_div_taxation(&revolut_interests_txns); + let (gross_revolut_div, tax_revolut) = compute_div_taxation(&revolut_div_txns); let (gross_revolut_sold, cost_revolut_sold) = compute_sold_taxation(&revolut_sold_transactions); Ok(TaxCalculationResult { - gross_income: gross_interests + gross_div + gross_revolut, - tax: tax_div + tax_revolut, + gross_interests: gross_etrade_interests + gross_revolut_interests, + gross_div: gross_etrade_div + gross_revolut_div, + tax: tax_etrade_div + tax_revolut, gross_sold: gross_sold + gross_revolut_sold, cost_sold: cost_sold + cost_revolut_sold, interests, @@ -541,6 +569,88 @@ pub fn run_taxation( mod tests { use super::*; + #[test] + fn test_round_to_grosz() { + // Normal rounding + assert_eq!(round_to_grosz(1.234), 1.23); + assert_eq!(round_to_grosz(1.235), 1.24); + assert_eq!(round_to_grosz(1.005), 1.01); + assert_eq!(round_to_grosz(0.0), 0.0); + // Rounds down when fraction < 0.5 + assert_eq!(round_to_grosz(4.1523 * 12.34), round_to_grosz(51.239482)); + } + + // Each transaction is rounded to grosz individually before summing. + // Two transactions of 1.005 PLN each: per-transaction gives 1.01 + 1.01 = 2.02, + // whereas rounding the raw sum (2.010) would give 2.01 — a different result. + #[test] + fn test_div_taxation_stepwise_rounding() -> Result<(), String> { + let transactions: Vec = vec![ + Transaction { + transaction_date: "N/A".to_string(), + gross: crate::Currency::PLN(1.005), + tax_paid: crate::Currency::PLN(0.0), + exchange_rate_date: "N/A".to_string(), + exchange_rate: 1.0, + company: None, + }, + Transaction { + transaction_date: "N/A".to_string(), + gross: crate::Currency::PLN(1.005), + tax_paid: crate::Currency::PLN(0.0), + exchange_rate_date: "N/A".to_string(), + exchange_rate: 1.0, + company: None, + }, + ]; + let (gross, _) = compute_div_taxation(&transactions); + // Per-transaction: round(1.005) + round(1.005) = 1.01 + 1.01 = 2.02 + assert_eq!(gross, 2.02); + // Sanity check: rounding the raw sum would give a different answer + assert_ne!(gross, round_to_grosz(1.005 + 1.005)); // round(2.01) = 2.01 + Ok(()) + } + + // Each sold transaction's FX-converted income and cost are rounded to grosz individually + // before summing. Two transactions where rate * amount = 1.005 each: per-transaction gives + // 1.01 + 1.01 = 2.02, whereas rounding the raw sum (2.01) would give 2.01. + #[test] + fn test_sold_taxation_stepwise_rounding() -> Result<(), String> { + let transactions: Vec = vec![ + SoldTransaction { + trade_date: "N/A".to_string(), + settlement_date: "N/A".to_string(), + acquisition_date: "N/A".to_string(), + income_us: 1.0, + cost_basis: 1.0, + exchange_rate_settlement_date: "N/A".to_string(), + exchange_rate_settlement: 1.005, + exchange_rate_acquisition_date: "N/A".to_string(), + exchange_rate_acquisition: 1.005, + company: Some("TFC".to_owned()), + }, + SoldTransaction { + trade_date: "N/A".to_string(), + settlement_date: "N/A".to_string(), + acquisition_date: "N/A".to_string(), + income_us: 1.0, + cost_basis: 1.0, + exchange_rate_settlement_date: "N/A".to_string(), + exchange_rate_settlement: 1.005, + exchange_rate_acquisition_date: "N/A".to_string(), + exchange_rate_acquisition: 1.005, + company: Some("TFC".to_owned()), + }, + ]; + let (gross, cost) = compute_sold_taxation(&transactions); + // Per-transaction: round(1.005) + round(1.005) = 1.01 + 1.01 = 2.02 + assert_eq!(gross, 2.02); + assert_eq!(cost, 2.02); + // Sanity check: rounding the raw sum would give a different answer + assert_ne!(gross, round_to_grosz(1.005 + 1.005)); // round(2.01) = 2.01 + Ok(()) + } + #[test] fn test_validate_file_names_invalid_path() { let files = vec![ diff --git a/src/main.rs b/src/main.rs index 3ddad3e..18a5756 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,7 +108,8 @@ fn main() { let pdfnames: Vec = pdfnames.map(|x| x.to_string()).collect(); let TaxCalculationResult { - gross_income: gross_div, + gross_interests, + gross_div, tax: tax_div, gross_sold, cost_sold, @@ -123,7 +124,8 @@ fn main() { Err(msg) => panic!("\nError: Unable to compute taxes. \n\nDetails: {msg}"), }; - let (presentation, warning) = rd.present_result(gross_div, tax_div, gross_sold, cost_sold); + let (presentation, warning) = + rd.present_result(gross_interests, gross_div, tax_div, gross_sold, cost_sold); presentation.iter().for_each(|x| println!("{x}")); if let Some(warn_msg) = warning { @@ -397,15 +399,16 @@ mod tests { match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { Ok(TaxCalculationResult { - gross_income: gross_div, + gross_interests, + gross_div, tax: tax_div, gross_sold, cost_sold, .. }) => { assert_eq!( - (gross_div, tax_div, gross_sold, cost_sold), - (6331.29, 871.17993, 0.0, 0.0), + (gross_interests, gross_div, tax_div, gross_sold, cost_sold), + (0.0, 6331.29, 871.17993, 0.0, 0.0), ); Ok(()) } @@ -430,15 +433,16 @@ mod tests { match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { Ok(TaxCalculationResult { - gross_income: gross_div, + gross_interests, + gross_div, tax: tax_div, gross_sold, cost_sold, .. }) => { assert_eq!( - (gross_div, tax_div, gross_sold, cost_sold), - (9142.319, 1207.08, 22988.617, 20163.5), + (gross_interests, gross_div, tax_div, gross_sold, cost_sold), + (0.0, 9142.319, 1207.08, 22988.62, 20163.5), ); Ok(()) } @@ -463,15 +467,16 @@ mod tests { match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { Ok(TaxCalculationResult { - gross_income: gross_div, + gross_interests, + gross_div, tax: tax_div, gross_sold, cost_sold, .. }) => { assert_eq!( - (gross_div, tax_div, gross_sold, cost_sold), - (86.93008, 0.0, 0.0, 0.0), + (gross_interests, gross_div, tax_div, gross_sold, cost_sold), + (86.93008, 0.0, 0.0, 0.0, 0.0), ); Ok(()) } @@ -497,15 +502,16 @@ mod tests { match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { Ok(TaxCalculationResult { - gross_income: gross_div, + gross_interests, + gross_div, tax: tax_div, gross_sold, cost_sold, .. }) => { assert_eq!( - (gross_div, tax_div, gross_sold, cost_sold), - (219.34755, 0.0, 89845.65, 44369.938), + (gross_interests, gross_div, tax_div, gross_sold, cost_sold), + (219.34755, 0.0, 0.0, 89845.65, 44369.938), ); Ok(()) } @@ -528,15 +534,16 @@ mod tests { match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { Ok(TaxCalculationResult { - gross_income: gross_div, + gross_interests, + gross_div, tax: tax_div, gross_sold, cost_sold, .. }) => { assert_eq!( - (gross_div, tax_div, gross_sold, cost_sold), - (0.66164804, 0.0, 0.0, 0.0), + (gross_interests, gross_div, tax_div, gross_sold, cost_sold), + (0.66164804, 0.0, 0.0, 0.0, 0.0), ); Ok(()) } diff --git a/src/pl.rs b/src/pl.rs index 3fe19d9..ad886e4 100644 --- a/src/pl.rs +++ b/src/pl.rs @@ -139,6 +139,18 @@ fn get_exchange_rates_from_cache( Ok(all_filled) } +/// Art. 63 §1a OP: ceil to 0.01 PLN (pełnych groszy w górę). +/// Applies to the base (podstawa) and tax for art. 30a pkt 1–3 PIT (odsetki). +fn ceil_to_grosz(val: f32) -> f32 { + (val * 100.0).ceil() / 100.0 +} + +/// Art. 63 §1 OP: round half-up to 1 PLN (pełnych złotych). +/// Applies to the base and tax for art. 30a pkt 4 PIT (dywidendy) and art. 30b PIT (sprzedaż). +fn round_half_up_to_zloty(val: f32) -> f32 { + (val + 0.5).floor() +} + impl etradeTaxReturnHelper::Residency for PL { // We search a exchange rate from a working day preceeding given date (settlement date for // etrade) @@ -241,20 +253,33 @@ impl etradeTaxReturnHelper::Residency for PL { fn present_result( &self, + gross_interests: f32, gross_div: f32, tax_div: f32, gross_sold: f32, cost_sold: f32, ) -> (Vec, Option) { let mut presentation: Vec = vec![]; - let tax_pl = 0.19 * gross_div; + + // Art. 30a ust. 1 pkt 1–3 PIT (odsetki): + // Art. 63 §1a OP — podstawa ceiled to grosz; 19% tax also ceiled to grosz. + let gross_interests_pln = ceil_to_grosz(gross_interests); + let tax_interests_pl = ceil_to_grosz(0.19 * gross_interests_pln); + + // Art. 30a ust. 1 pkt 4 PIT (dywidendy): + // Art. 63 §1 OP — podstawa rounded half-up to full złoty; 19% tax also rounded half-up. + let gross_div_pln = round_half_up_to_zloty(gross_div); + let tax_div_pl = round_half_up_to_zloty(0.19 * gross_div_pln); + + let tax_pl = tax_interests_pl + tax_div_pl; + presentation.push(format!( - "(DYWIDENDY) PRZYCHOD Z ZAGRANICY: {:.2} PLN", - gross_div + "(DYWIDENDY+ODSETKI) PRZYCHOD Z ZAGRANICY: {:.2} PLN (w tym: {:.2}zł od odsetek i {:.2}zł od dywidend)", + gross_div_pln + gross_interests_pln, gross_interests_pln, gross_div_pln )); presentation.push(format!( - "===> (DYWIDENDY) ZRYCZALTOWANY PODATEK: {:.2} PLN", - tax_pl + "===> (DYWIDENDY+ODSETKI) ZRYCZALTOWANY PODATEK: {:.2} PLN (w tym: {:.2}zł od odsetek i {:.2}zł od dywidend)", + tax_pl, tax_interests_pl, tax_div_pl )); presentation.push(format!( "===> (DYWIDENDY) PODATEK ZAPLACONY ZAGRANICA: {:.2} PLN", @@ -279,24 +304,49 @@ impl etradeTaxReturnHelper::Residency for PL { #[cfg(test)] mod tests { use super::*; + #[test] + fn test_ceil_to_grosz() { + // Art. 63 §1a OP: always ceil to 0.01 PLN + assert_eq!(ceil_to_grosz(1.231), 1.24); + assert_eq!(ceil_to_grosz(1.230), 1.23); + assert_eq!(ceil_to_grosz(1.001), 1.01); + assert_eq!(ceil_to_grosz(0.0), 0.0); + // Never rounds down, even for tiny fractions + assert_eq!(ceil_to_grosz(0.441), 0.45); + } + + #[test] + fn test_round_half_up_to_zloty() { + // Art. 63 §1 OP: round half-up to full PLN + assert_eq!(round_half_up_to_zloty(100.0), 100.0); + assert_eq!(round_half_up_to_zloty(100.4), 100.0); + assert_eq!(round_half_up_to_zloty(100.5), 101.0); + assert_eq!(round_half_up_to_zloty(100.9), 101.0); + assert_eq!(round_half_up_to_zloty(0.0), 0.0); + assert_eq!(round_half_up_to_zloty(0.49), 0.0); + assert_eq!(round_half_up_to_zloty(0.5), 1.0); + } + #[test] fn test_present_result_pl() -> Result<(), String> { let rd: Box = Box::new(PL {}); + let gross_interests = 0.0f32; let gross_div = 100.0f32; let tax_div = 15.0f32; let gross_sold = 1000.0f32; let cost_sold = 10.0f32; let ref_results: Vec = vec![ - "(DYWIDENDY) PRZYCHOD Z ZAGRANICY: 100.00 PLN".to_string(), - "===> (DYWIDENDY) ZRYCZALTOWANY PODATEK: 19.00 PLN".to_string(), + "(DYWIDENDY+ODSETKI) PRZYCHOD Z ZAGRANICY: 100.00 PLN (w tym: 0.00zł od odsetek i 100.00zł od dywidend)".to_string(), + "===> (DYWIDENDY+ODSETKI) ZRYCZALTOWANY PODATEK: 19.00 PLN (w tym: 0.00zł od odsetek i 19.00zł od dywidend)".to_string(), "===> (DYWIDENDY) PODATEK ZAPLACONY ZAGRANICA: 15.00 PLN".to_string(), "===> (SPRZEDAZ AKCJI) PRZYCHOD Z ZAGRANICY: 1000.00 PLN".to_string(), "===> (SPRZEDAZ AKCJI) KOSZT UZYSKANIA PRZYCHODU: 10.00 PLN".to_string(), ]; - let (results, _) = rd.present_result(gross_div, tax_div, gross_sold, cost_sold); + let (results, _) = + rd.present_result(gross_interests, gross_div, tax_div, gross_sold, cost_sold); results .iter() @@ -354,20 +404,22 @@ mod tests { fn test_present_result_double_taxation_warning_pl() -> Result<(), String> { let rd: Box = Box::new(PL {}); + let gross_interests = 0.0f32; let gross_div = 100.0f32; let tax_div = 30.0f32; let gross_sold = 1000.0f32; let cost_sold = 10.0f32; let ref_results: Vec = vec![ - "(DYWIDENDY) PRZYCHOD Z ZAGRANICY: 100.00 PLN".to_string(), - "===> (DYWIDENDY) ZRYCZALTOWANY PODATEK: 19.00 PLN".to_string(), + "(DYWIDENDY+ODSETKI) PRZYCHOD Z ZAGRANICY: 100.00 PLN (w tym: 0.00zł od odsetek i 100.00zł od dywidend)".to_string(), + "===> (DYWIDENDY+ODSETKI) ZRYCZALTOWANY PODATEK: 19.00 PLN (w tym: 0.00zł od odsetek i 19.00zł od dywidend)".to_string(), "===> (DYWIDENDY) PODATEK ZAPLACONY ZAGRANICA: 30.00 PLN".to_string(), "===> (SPRZEDAZ AKCJI) PRZYCHOD Z ZAGRANICY: 1000.00 PLN".to_string(), "===> (SPRZEDAZ AKCJI) KOSZT UZYSKANIA PRZYCHODU: 10.00 PLN".to_string(), ]; - let (results, warning) = rd.present_result(gross_div, tax_div, gross_sold, cost_sold); + let (results, warning) = + rd.present_result(gross_interests, gross_div, tax_div, gross_sold, cost_sold); results .iter() @@ -384,6 +436,41 @@ mod tests { Ok(()) } + // Art. 63 §1a OP: interests base and tax are ceiled to grosz (never rounded down). + // gross_interests=0.441 → ceil to 0.45; tax = ceil(0.19 * 0.45) = ceil(0.0855) = 0.09 + #[test] + fn test_present_result_interests_ceil_to_grosz() { + let rd: Box = Box::new(PL {}); + let (results, _) = rd.present_result(0.441, 0.0, 0.0, 0.0, 0.0); + assert_eq!(results[0], "(DYWIDENDY+ODSETKI) PRZYCHOD Z ZAGRANICY: 0.45 PLN (w tym: 0.45zł od odsetek i 0.00zł od dywidend)"); + assert_eq!(results[1], "===> (DYWIDENDY+ODSETKI) ZRYCZALTOWANY PODATEK: 0.09 PLN (w tym: 0.09zł od odsetek i 0.00zł od dywidend)"); + } + + // Art. 63 §1 OP: dividends base and tax are rounded half-up to full złoty (not grosz). + // gross_div=100.5 → rounds up to 101; tax = round_half_up(0.19 * 101) = round_half_up(19.19) = 19 + // gross_div=100.4 → rounds down to 100; tax = round_half_up(0.19 * 100) = 19 + #[test] + fn test_present_result_dividends_round_to_zloty() { + let rd: Box = Box::new(PL {}); + let (results_up, _) = rd.present_result(0.0, 100.5, 0.0, 0.0, 0.0); + assert_eq!(results_up[0], "(DYWIDENDY+ODSETKI) PRZYCHOD Z ZAGRANICY: 101.00 PLN (w tym: 0.00zł od odsetek i 101.00zł od dywidend)"); + assert_eq!(results_up[1], "===> (DYWIDENDY+ODSETKI) ZRYCZALTOWANY PODATEK: 19.00 PLN (w tym: 0.00zł od odsetek i 19.00zł od dywidend)"); + + let (results_down, _) = rd.present_result(0.0, 100.4, 0.0, 0.0, 0.0); + assert_eq!(results_down[0], "(DYWIDENDY+ODSETKI) PRZYCHOD Z ZAGRANICY: 100.00 PLN (w tym: 0.00zł od odsetek i 100.00zł od dywidend)"); + } + + // Both income types combined: interests use §1a (ceil to grosz), dividends use §1 (half-up to złoty). + // gross_interests=0.441 → 0.45, tax_interests=0.09 + // gross_div=100.5 → 101, tax_div=19 → combined: 101.45, tax 19.09 + #[test] + fn test_present_result_combined_rounding() { + let rd: Box = Box::new(PL {}); + let (results, _) = rd.present_result(0.441, 100.5, 0.0, 0.0, 0.0); + assert_eq!(results[0], "(DYWIDENDY+ODSETKI) PRZYCHOD Z ZAGRANICY: 101.45 PLN (w tym: 0.45zł od odsetek i 101.00zł od dywidend)"); + assert_eq!(results[1], "===> (DYWIDENDY+ODSETKI) ZRYCZALTOWANY PODATEK: 19.09 PLN (w tym: 0.09zł od odsetek i 19.00zł od dywidend)"); + } + #[test] fn test_is_non_working_day() -> Result<(), String> { let date = chrono::NaiveDate::parse_from_str(&"11/01/24", "%m/%d/%y") diff --git a/src/us.rs b/src/us.rs index d305a01..5a3defa 100644 --- a/src/us.rs +++ b/src/us.rs @@ -18,13 +18,15 @@ impl etradeTaxReturnHelper::Residency for US { fn present_result( &self, + gross_interests: f32, gross_div: f32, tax_div: f32, gross_sold: f32, cost_sold: f32, ) -> (Vec, Option) { + let total_gross_div = gross_interests + gross_div; let mut presentation: Vec = vec![]; - presentation.push(format!("===> (DIVIDENDS) INCOME: ${:.2}", gross_div)); + presentation.push(format!("===> (DIVIDENDS) INCOME: ${:.2}", total_gross_div)); presentation.push(format!("===> (DIVIDENDS) TAX PAID: ${:.2}", tax_div)); presentation.push(format!("===> (SOLD STOCK) INCOME: ${:.2}", gross_sold)); presentation.push(format!( @@ -54,7 +56,7 @@ mod tests { "===> (SOLD STOCK) TAX DEDUCTIBLE COST: $10.00".to_string(), ]; - let (results, _) = rd.present_result(gross_div, tax_div, gross_sold, cost_sold); + let (results, _) = rd.present_result(0.0f32, gross_div, tax_div, gross_sold, cost_sold); results .iter() From 1096aa5050c1c6203913156a93bc72f7a19fbf23 Mon Sep 17 00:00:00 2001 From: Mateusz Bronk Date: Sat, 4 Apr 2026 16:55:59 +0200 Subject: [PATCH 2/3] Add opt-in per-transaction rounding (default off per Art. 63 OP) Per tax advisor consultation with Naczelna Izba Skarbowa and Art. 63 Ordynacja Podatkowa, rounding should only be applied to final tax base and tax amount sums, not to individual transactions. Change default behavior to carry full f32 precision through per-transaction FX conversions. The previous per-transaction rounding to grosz is preserved as an opt-in flag: - CLI: --round-per-transaction - GUI: Options menu toggle (MenuBar) Also add default-run to Cargo.toml to resolve binary ambiguity. Signed-off-by: Mateusz Bronk --- Cargo.toml | 1 + src/gui.rs | 24 ++++++++++++++-- src/lib.rs | 81 ++++++++++++++++++++++++++++++++++++++--------------- src/main.rs | 52 ++++++++++++++++++++++++++++++---- 4 files changed, 127 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 70309e8..a1c5d33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ readme = "README.md" keywords = ["etrade", "revolut"] repository = "https://github.com/jczaja/e-trade-tax-return-pl-helper" homepage = "https://github.com/jczaja/e-trade-tax-return-pl-helper" +default-run = "etradeTaxReturnHelper" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html exclude = [ diff --git a/src/gui.rs b/src/gui.rs index dd5a29e..6746e42 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -9,9 +9,10 @@ use fltk::{ browser::MultiBrowser, button::Button, dialog, - enums::{Event, Font, FrameType, Key}, + enums::{Event, Font, FrameType, Key, Shortcut}, frame::Frame, group::Pack, + menu::{MenuBar, MenuFlag}, prelude::*, text::{TextBuffer, TextDisplay}, window, @@ -77,6 +78,7 @@ fn create_clear_documents( fn create_execute_documents( browser: Rc>, + menubar: Rc>, tdisplay: Rc>, sdisplay: Rc>, ndisplay: Rc>, @@ -118,6 +120,12 @@ fn create_execute_documents( buffer.set_text(""); tbuffer.set_text(""); nbuffer.set_text("Running..."); + let round_per_transaction = { + let mb = menubar.borrow(); + mb.find_item("Options/Round per transaction") + .map(|item| item.value()) + .unwrap_or(false) + }; let rd: Box = Box::new(PL {}); let etradeTaxReturnHelper::TaxCalculationResult { gross_interests, @@ -130,7 +138,7 @@ fn create_execute_documents( revolut_dividends_transactions: revolut_transactions, sold_transactions, revolut_sold_transactions, - } = match run_taxation(&rd, file_names,false, false) { + } = match run_taxation(&rd, file_names, false, false, round_per_transaction) { Ok(res) => { nbuffer.set_text("Finished.\n\n (Double check if generated tax data (Summary) makes sense and then copy it to your tax form)"); res @@ -229,7 +237,16 @@ pub fn run_gui() { wind.make_resizable(true); - let mut uberpack = Pack::new(0, 0, WIND_SIZE_X as i32, WIND_SIZE_Y as i32, ""); + let mut menubar = MenuBar::new(0, 0, WIND_SIZE_X, 25, ""); + menubar.add( + "Options/Round per transaction", + Shortcut::None, + MenuFlag::Toggle, + |_| {}, + ); + let menubar = Rc::new(RefCell::new(menubar)); + + let mut uberpack = Pack::new(0, 25, WIND_SIZE_X as i32, WIND_SIZE_Y as i32 - 25, ""); let mut pack = Pack::new(0, 0, WIND_SIZE_X as i32, WIND_SIZE_Y / 2 as i32, ""); pack.set_type(fltk::group::PackType::Horizontal); @@ -328,6 +345,7 @@ pub fn run_gui() { ); create_execute_documents( browser.clone(), + menubar.clone(), tdisplay.clone(), sdisplay.clone(), ndisplay.clone(), diff --git a/src/lib.rs b/src/lib.rs index e1e419a..7adbe99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -298,32 +298,64 @@ fn round_to_grosz(val: f32) -> f32 { (val * 100.0).round() / 100.0 } -fn compute_div_taxation(transactions: &Vec) -> (f32, f32) { +fn compute_div_taxation( + transactions: &Vec, + round_per_transaction: bool, +) -> (f32, f32) { // Gross income from dividends in target currency (PLN, EUR etc.) - // Each transaction's FX-converted amount is rounded to 0.01 before summing. let gross_us_pl: f32 = transactions .iter() - .map(|x| round_to_grosz(x.exchange_rate * x.gross.value() as f32)) + .map(|x| { + let v = x.exchange_rate * x.gross.value() as f32; + if round_per_transaction { + round_to_grosz(v) + } else { + v + } + }) .sum(); // Tax paid in US in PLN let tax_us_pl: f32 = transactions .iter() - .map(|x| round_to_grosz(x.exchange_rate * x.tax_paid.value() as f32)) + .map(|x| { + let v = x.exchange_rate * x.tax_paid.value() as f32; + if round_per_transaction { + round_to_grosz(v) + } else { + v + } + }) .sum(); (gross_us_pl, tax_us_pl) } -fn compute_sold_taxation(transactions: &Vec) -> (f32, f32) { +fn compute_sold_taxation( + transactions: &Vec, + round_per_transaction: bool, +) -> (f32, f32) { // Net income from sold stock in target currency (PLN, EUR etc.) - // Each transaction's FX-converted amount is rounded to 0.01 before summing. let gross_us_pl: f32 = transactions .iter() - .map(|x| round_to_grosz(x.exchange_rate_settlement * x.income_us)) + .map(|x| { + let v = x.exchange_rate_settlement * x.income_us; + if round_per_transaction { + round_to_grosz(v) + } else { + v + } + }) .sum(); // Cost of income e.g. cost_basis[target currency] let cost_us_pl: f32 = transactions .iter() - .map(|x| round_to_grosz(x.exchange_rate_acquisition * x.cost_basis)) + .map(|x| { + let v = x.exchange_rate_acquisition * x.cost_basis; + if round_per_transaction { + round_to_grosz(v) + } else { + v + } + }) .sum(); (gross_us_pl, cost_us_pl) } @@ -387,6 +419,7 @@ pub fn run_taxation( names: Vec, per_company: bool, multiyear: bool, + round_per_transaction: bool, ) -> Result { validate_file_names(&names)?; @@ -532,9 +565,10 @@ pub fn run_taxation( println!("{}", per_company_report); } - let (gross_etrade_interests, _) = compute_div_taxation(&interests); - let (gross_etrade_div, tax_etrade_div) = compute_div_taxation(&transactions); - let (gross_sold, cost_sold) = compute_sold_taxation(&sold_transactions); + let (gross_etrade_interests, _) = compute_div_taxation(&interests, round_per_transaction); + let (gross_etrade_div, tax_etrade_div) = + compute_div_taxation(&transactions, round_per_transaction); + let (gross_sold, cost_sold) = compute_sold_taxation(&sold_transactions, round_per_transaction); // Split Revolut transactions: savings interests (company=None, art. 30a pkt 1-3) // vs stock dividends (company=Some, art. 30a pkt 4). @@ -548,9 +582,12 @@ pub fn run_taxation( .filter(|x| x.company.is_some()) .cloned() .collect(); - let (gross_revolut_interests, _) = compute_div_taxation(&revolut_interests_txns); - let (gross_revolut_div, tax_revolut) = compute_div_taxation(&revolut_div_txns); - let (gross_revolut_sold, cost_revolut_sold) = compute_sold_taxation(&revolut_sold_transactions); + let (gross_revolut_interests, _) = + compute_div_taxation(&revolut_interests_txns, round_per_transaction); + let (gross_revolut_div, tax_revolut) = + compute_div_taxation(&revolut_div_txns, round_per_transaction); + let (gross_revolut_sold, cost_revolut_sold) = + compute_sold_taxation(&revolut_sold_transactions, round_per_transaction); Ok(TaxCalculationResult { gross_interests: gross_etrade_interests + gross_revolut_interests, gross_div: gross_etrade_div + gross_revolut_div, @@ -603,7 +640,7 @@ mod tests { company: None, }, ]; - let (gross, _) = compute_div_taxation(&transactions); + let (gross, _) = compute_div_taxation(&transactions, true); // Per-transaction: round(1.005) + round(1.005) = 1.01 + 1.01 = 2.02 assert_eq!(gross, 2.02); // Sanity check: rounding the raw sum would give a different answer @@ -642,7 +679,7 @@ mod tests { company: Some("TFC".to_owned()), }, ]; - let (gross, cost) = compute_sold_taxation(&transactions); + let (gross, cost) = compute_sold_taxation(&transactions, true); // Per-transaction: round(1.005) + round(1.005) = 1.01 + 1.01 = 2.02 assert_eq!(gross, 2.02); assert_eq!(cost, 2.02); @@ -722,7 +759,7 @@ mod tests { exchange_rate: 4.0, company: Some("INTEL CORP".to_owned()), }]; - assert_eq!(compute_div_taxation(&transactions), (400.0, 100.0)); + assert_eq!(compute_div_taxation(&transactions, false), (400.0, 100.0)); Ok(()) } @@ -748,7 +785,7 @@ mod tests { }, ]; assert_eq!( - compute_div_taxation(&transactions), + compute_div_taxation(&transactions, false), (400.0 + 126.0 * 3.5, 100.0 + 10.0 * 3.5) ); Ok(()) @@ -774,7 +811,7 @@ mod tests { }, ]; assert_eq!( - compute_div_taxation(&transactions), + compute_div_taxation(&transactions, false), (0.44 * 1.0 + 0.45 * 1.0, 0.0) ); Ok(()) @@ -801,7 +838,7 @@ mod tests { }, ]; assert_eq!( - compute_div_taxation(&transactions), + compute_div_taxation(&transactions, false), (0.44 * 2.0 + 0.45 * 3.0, 0.0) ); Ok(()) @@ -823,7 +860,7 @@ mod tests { company: Some("TFC".to_owned()), }]; assert_eq!( - compute_sold_taxation(&transactions), + compute_sold_taxation(&transactions, false), (100.0 * 5.0, 70.0 * 6.0) ); Ok(()) @@ -859,7 +896,7 @@ mod tests { }, ]; assert_eq!( - compute_sold_taxation(&transactions), + compute_sold_taxation(&transactions, false), (100.0 * 5.0 + 10.0 * 2.0, 70.0 * 6.0 + 4.0 * 3.0) ); Ok(()) diff --git a/src/main.rs b/src/main.rs index 18a5756..13165a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,6 +58,12 @@ fn create_cmd_line_pattern(myapp: Command) -> Command { .help("Allow processing documents across more than year") .action(clap::ArgAction::SetTrue) ) + .arg( + Arg::new("round-per-transaction") + .long("round-per-transaction") + .help("Round each FX-converted amount to grosz before summing (off by default)") + .action(clap::ArgAction::SetTrue) + ) } fn configure_dataframes_format() { @@ -119,6 +125,7 @@ fn main() { pdfnames, matches.get_flag("per-company"), matches.get_flag("multiyear"), + matches.get_flag("round-per-transaction"), ) { Ok(res) => res, Err(msg) => panic!("\nError: Unable to compute taxes. \n\nDetails: {msg}"), @@ -376,7 +383,7 @@ mod tests { .expect_and_log("error getting financial documents names"); let pdfnames: Vec = pdfnames.map(|x| x.to_string()).collect(); - match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { + match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false, false) { Ok(_) => panic!("Expected an error from run_taxation, but got Ok"), Err(_) => Ok(()), // Expected error, test passes } @@ -397,7 +404,7 @@ mod tests { .expect_and_log("error getting brokarage statements pdfs names"); let pdfnames: Vec = pdfnames.map(|x| x.to_string()).collect(); - match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { + match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false, false) { Ok(TaxCalculationResult { gross_interests, gross_div, @@ -431,7 +438,40 @@ mod tests { .expect_and_log("error getting brokarage statements pdfs names"); let pdfnames: Vec = pdfnames.map(|x| x.to_string()).collect(); - match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { + match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false, false) { + Ok(TaxCalculationResult { + gross_interests, + gross_div, + tax: tax_div, + gross_sold, + cost_sold, + .. + }) => { + assert_eq!( + (gross_interests, gross_div, tax_div, gross_sold, cost_sold), + (0.0, 9142.319, 1207.08, 22988.617, 20163.5), + ); + Ok(()) + } + Err(x) => panic!("Error in taxation process: {x}"), + } + } + + #[test] + fn test_revolut_sold_and_dividends_round_per_transaction() -> Result<(), clap::Error> { + let myapp = Command::new("etradeTaxHelper").arg_required_else_help(true); + let rd: Box = Box::new(pl::PL {}); + + let matches = create_cmd_line_pattern(myapp).get_matches_from(vec![ + "mytest", + "revolut_data/trading-pnl-statement_2022-11-01_2024-09-01_pl-pl_e989f4.csv", + ]); + let pdfnames = matches + .get_many::("financial documents") + .expect_and_log("error getting brokarage statements pdfs names"); + let pdfnames: Vec = pdfnames.map(|x| x.to_string()).collect(); + + match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false, true) { Ok(TaxCalculationResult { gross_interests, gross_div, @@ -465,7 +505,7 @@ mod tests { .expect_and_log("error getting brokarage statements pdfs names"); let pdfnames: Vec = pdfnames.map(|x| x.to_string()).collect(); - match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { + match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false, false) { Ok(TaxCalculationResult { gross_interests, gross_div, @@ -500,7 +540,7 @@ mod tests { .expect_and_log("error getting brokarage statements pdfs names"); let pdfnames: Vec = pdfnames.map(|x| x.to_string()).collect(); - match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { + match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false, false) { Ok(TaxCalculationResult { gross_interests, gross_div, @@ -532,7 +572,7 @@ mod tests { .expect_and_log("error getting brokarage statements pdfs names"); let pdfnames: Vec = pdfnames.map(|x| x.to_string()).collect(); - match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false) { + match etradeTaxReturnHelper::run_taxation(&rd, pdfnames, false, false, false) { Ok(TaxCalculationResult { gross_interests, gross_div, From f74f7bdc6edc2c240a9b0f4c6ae3545aca6eddc1 Mon Sep 17 00:00:00 2001 From: Mateusz Bronk Date: Sat, 4 Apr 2026 18:43:57 +0200 Subject: [PATCH 3/3] fixup! Fix PLN amount rounding for compliance with tax rules Signed-off-by: Mateusz Bronk --- src/de.rs | 4 ++-- src/us.rs | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/de.rs b/src/de.rs index 1762d79..a7b5b5b 100644 --- a/src/de.rs +++ b/src/de.rs @@ -66,7 +66,7 @@ impl etradeTaxReturnHelper::Residency for DE { let total_gross_div = gross_interests + gross_div; let mut presentation: Vec = vec![]; presentation.push(format!( - "===> (DIVIDENDS) INCOME: {:.2} EUR", + "===> (DIVIDENDS+INTERESTS) INCOME: {:.2} EUR", total_gross_div )); presentation.push(format!("===> (DIVIDENDS) TAX PAID: {:.2} EUR", tax_div)); @@ -94,7 +94,7 @@ mod tests { let cost_sold = 10.0f32; let ref_results: Vec = vec![ - "===> (DIVIDENDS) INCOME: 100.00 EUR".to_string(), + "===> (DIVIDENDS+INTERESTS) INCOME: 100.00 EUR".to_string(), "===> (DIVIDENDS) TAX PAID: 15.00 EUR".to_string(), "===> (SOLD STOCK) INCOME: 1000.00 EUR".to_string(), "===> (SOLD STOCK) TAX DEDUCTIBLE COST: 10.00 EUR".to_string(), diff --git a/src/us.rs b/src/us.rs index 5a3defa..b3fe578 100644 --- a/src/us.rs +++ b/src/us.rs @@ -26,7 +26,10 @@ impl etradeTaxReturnHelper::Residency for US { ) -> (Vec, Option) { let total_gross_div = gross_interests + gross_div; let mut presentation: Vec = vec![]; - presentation.push(format!("===> (DIVIDENDS) INCOME: ${:.2}", total_gross_div)); + presentation.push(format!( + "===> (DIVIDENDS+INTERESTS) INCOME: ${:.2}", + total_gross_div + )); presentation.push(format!("===> (DIVIDENDS) TAX PAID: ${:.2}", tax_div)); presentation.push(format!("===> (SOLD STOCK) INCOME: ${:.2}", gross_sold)); presentation.push(format!( @@ -50,7 +53,7 @@ mod tests { let cost_sold = 10.0f32; let ref_results: Vec = vec![ - "===> (DIVIDENDS) INCOME: $100.00".to_string(), + "===> (DIVIDENDS+INTERESTS) INCOME: $100.00".to_string(), "===> (DIVIDENDS) TAX PAID: $15.00".to_string(), "===> (SOLD STOCK) INCOME: $1000.00".to_string(), "===> (SOLD STOCK) TAX DEDUCTIBLE COST: $10.00".to_string(),