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/de.rs b/src/de.rs index 2904a1a..a7b5b5b 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+INTERESTS) 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!( @@ -89,13 +94,13 @@ 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(), ]; - 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..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,9 +120,16 @@ 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_income: gross_div, + gross_interests, + gross_div, tax: tax_div, gross_sold, cost_sold, @@ -129,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 @@ -139,7 +148,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); @@ -228,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); @@ -327,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 e5cdd9a..7adbe99 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,69 @@ fn create_client() -> reqwest::blocking::Client { client } -fn compute_div_taxation(transactions: &Vec) -> (f32, f32) { +/// 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, + round_per_transaction: bool, +) -> (f32, f32) { // Gross income from dividends in target currency (PLN, EUR etc.) let gross_us_pl: f32 = transactions .iter() - .map(|x| 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| 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.) let gross_us_pl: f32 = transactions .iter() - .map(|x| 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| 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) } @@ -374,6 +419,7 @@ pub fn run_taxation( names: Vec, per_company: bool, multiyear: bool, + round_per_transaction: bool, ) -> Result { validate_file_names(&names)?; @@ -519,14 +565,33 @@ 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_sold, cost_sold) = compute_sold_taxation(&sold_transactions); - let (gross_revolut, tax_revolut) = compute_div_taxation(&revolut_dividends_transactions); - let (gross_revolut_sold, cost_revolut_sold) = compute_sold_taxation(&revolut_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). + 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, 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_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 +606,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, 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 + 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, 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); + // 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![ @@ -612,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(()) } @@ -638,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(()) @@ -664,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(()) @@ -691,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(()) @@ -713,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(()) @@ -749,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 3ddad3e..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() { @@ -108,7 +114,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, @@ -118,12 +125,14 @@ 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}"), }; - 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 { @@ -374,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 } @@ -395,17 +404,18 @@ 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_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(()) } @@ -428,17 +438,51 @@ 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_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(()) } @@ -461,17 +505,18 @@ 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_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(()) } @@ -495,17 +540,18 @@ 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_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(()) } @@ -526,17 +572,18 @@ 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_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..b3fe578 100644 --- a/src/us.rs +++ b/src/us.rs @@ -18,13 +18,18 @@ 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+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!( @@ -48,13 +53,13 @@ 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(), ]; - 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()