From 47b058a8b26c8fd594d0e115eabc90cae4469f7b Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 13:00:19 +0200 Subject: [PATCH 01/31] degiro support --- .gitignore | 2 + README.md | 103 +++++++++++--- media/degiro.png | Bin 0 -> 31058 bytes pom.xml | 30 ++++- .../solutions/cockroach/CnbYearRatesSource.kt | 126 ++++++++++++++++++ .../cz/solutions/cockroach/CockroachConfig.kt | 20 +++ .../cz/solutions/cockroach/CockroachMain.kt | 48 ++++++- .../kotlin/cz/solutions/cockroach/Currency.kt | 5 + .../cockroach/DegiroAccountStatementParser.kt | 116 ++++++++++++++++ .../cz/solutions/cockroach/DividendRecord.kt | 3 +- .../cz/solutions/cockroach/DividendReport.kt | 34 ++--- .../cockroach/DividendReportPdfGenerator.kt | 89 ++++++++++--- .../cockroach/DividentReportPreparation.kt | 117 +++++++++++----- .../cockroach/EsppReportPreparation.kt | 2 +- .../cockroach/ExchangeRateProvider.kt | 2 +- .../cockroach/ExchangeRatesReader.kt | 29 ++-- .../cockroach/PrintableCzkDividend.kt | 7 + .../solutions/cockroach/PrintableDividend.kt | 4 +- .../kotlin/cz/solutions/cockroach/Report.kt | 4 +- .../cockroach/RsuReportPreparation.kt | 2 +- .../cockroach/SalesReportPreparation.kt | 4 +- .../cockroach/TabularExchangeRateProvider.kt | 30 ++--- .../cz/solutions/cockroach/TaxRecord.kt | 3 +- .../solutions/cockroach/TaxReversalRecord.kt | 3 +- .../YearConstantExchangeRateProvider.kt | 54 ++++++-- .../DegiroAccountStatementParserTest.kt | 121 +++++++++++++++++ .../cockroach/SalesReportPreparationTest.kt | 4 +- .../TabularExchangeRateProviderTest.kt | 10 +- 28 files changed, 827 insertions(+), 145 deletions(-) create mode 100644 media/degiro.png create mode 100644 src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/Currency.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt create mode 100644 src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt diff --git a/.gitignore b/.gitignore index e3cf182..ea74b0b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /.idea/ /.claude/ comparison_workdir/ +/input/ +/output/ \ No newline at end of file diff --git a/README.md b/README.md index e2d46dc..635f0d7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,16 @@ # Cockroach will help you with your taxes -This small utility is for people using [Charles Schwab brokerage](https://www.schwab.com/) and/or -[E-Trade](https://www.etrade.com/) services in the [Czech Republic](https://en.wikipedia.org/wiki/Czech_Republic). +This small utility is for people using [Charles Schwab brokerage](https://www.schwab.com/), +[E-Trade](https://www.etrade.com/) and/or [Degiro](https://www.degiro.com/) services in the +[Czech Republic](https://en.wikipedia.org/wiki/Czech_Republic). -The program reads the Schwab JSON export of your stock transactions and optionally an E-Trade Gain and Loss CSV -export, then creates a summary of your sales and purchases for the tax year. +The program reads the Schwab JSON export of your stock transactions, optionally an E-Trade Gain and Loss +XLSX/CSV export, and optionally a Degiro account statement (`.xls`), then creates a summary of your sales, +purchases and dividends for the tax year. + +All input files referenced in this README (and the YAML config) are assumed to live under the +`input/` folder at the repository root (which is git-ignored). See the [Input layout](#input-layout) +section below for the exact directory structure. # Obtaining Schwab CSV export for last year @@ -24,6 +30,8 @@ export, then creates a summary of your sales and purchases for the tax year. 5. Select "JSON" radio button, click "Export" ![](media/image1.png) +6. Save the JSON file into `input/`. + # Obtaining E-Trade Gain and Loss XLSX export @@ -33,8 +41,8 @@ export, then creates a summary of your sales and purchases for the tax year. ![](media/gains_losses_0.jpg) 3. Click **Download** → **Download Expanded** to export the data as XLSX. - -4. Save the xlsx into directory called 'sales' + +4. Save the xlsx into `input/etrade/sales/`. # Obtaining E-Trade ESPP confirmation PDFs @@ -44,8 +52,8 @@ export, then creates a summary of your sales and purchases for the tax year. ![](media/espp_confirm_0.jpg) 3. Download all available confirmations. - -4. Save the PDFs into directory called 'espp' + +4. Save the PDFs into `input/etrade/espp/`. # Obtaining E-Trade RSU release confirmation PDFs @@ -55,8 +63,8 @@ export, then creates a summary of your sales and purchases for the tax year. ![](media/rsu_confirm_0.jpg) 3. Download all available confirmations. - -4. Save the PDFs into directory called 'rsu' + +4. Save the PDFs into `input/etrade/rsu/`. # Obtaining E-Trade Dividends XLSX export @@ -69,24 +77,83 @@ export, then creates a summary of your sales and purchases for the tax year. 3. Download the report as **Excel**. -4. Save the XLSX file into directory called 'dividends' +4. Save the XLSX file into `input/etrade/dividends/`. + +# Obtaining Degiro account statement + +1. Log in to [Degiro Account Overview](https://trader.degiro.nl/trader/#/account-overview) and open **Inbox → Account Statement** + (Czech: *Inbox → Přehled účtu*). + +2. Set the date range to cover the relevant tax year (overlap of a few days at both ends is + safe; only rows whose **value date** (`Datum valuty`) falls inside the requested year are + used for the report). + ![](media/degiro.png) +3. Export as **XLS**. + +4. Save the file into `input/` (e.g. `input/Accounts_Degiro_2025.xls`). + +Notes: +- Only `Dividenda` and `Daň z dividendy` rows are used. +- `ADR/GDR Pass-Through poplatek` rows are custody fees (not withholding tax) and are + ignored; the total amount that was skipped is logged on stdout for transparency. +- All currencies present in the file (USD, EUR, CZK, ...) are handled; the daily CNB rate + for the value date is used for FX conversion. - +# Input layout +All inputs (broker exports and the YAML config) live under `input/`. A typical layout is: +``` +input/ +├── config.yaml +├── schwab-export.json # Schwab JSON export +├── Accounts_Degiro.xls # Degiro account statement +└── etrade/ # E-Trade data directory + ├── rsu/ *.pdf # RSU release confirmations + ├── espp/ *.pdf # ESPP purchase confirmations + ├── dividends/ *.xlsx # single dividends export + └── sales/ *.xlsx # single Gain & Loss export +``` + +Each broker is optional; include only what applies to you. # Running the application +There are two ways to invoke the tool: + +## YAML config (recommended for multi-broker setups) + +Create `input/config.yaml` describing the inputs and run with a single argument: + +```yaml +year: 2025 +outputDir: ./output +schwab: ./input/schwab-export.json # optional +etrade: ./input/etrade # optional, layout shown above +etradeBenefitHistory: ./input/BenefitHistory.xlsx # optional; alternative to etrade/rsu + etrade/espp +degiro: # optional, list of Degiro .xls files + - ./input/Accounts_Degiro_2024.xls +``` + +``` +java -jar target/cockroach-0.3-SNAPSHOT.jar input/config.yaml +``` + +At least one of `schwab`, `etrade`, `degiro` must be present. + +## Positional arguments (legacy, Schwab + E-Trade only) + - Compile and run cockroach/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt - it gets 3 required command line arguments - path to Schwab JSON export, year, and - output dir. An optional 4th argument specifies the path to the E-Trade Gain and Loss CSV file. + output dir. An optional 4th argument specifies the path to the E-Trade data directory. - it uses templates located here: cockroach/src/main/resources/cz/solutions/cockroach -- the output are 4 simple .md files and an HTML guide +- the output are PDF reports and an HTML guide for both fixed and dynamic exchange-rate + variants. - In InteliJ IDEA, you can convert the md files into pdf in Markdown export options under Tools \> Markdown Converter menu.\ @@ -98,11 +165,15 @@ mvn clean install -am mvn clean install shade:shade -java -jar target/cockroach-0.2-SNAPSHOT.jar /tmp/219114411.json 2025 /tmp/taxes +java -jar target/cockroach-0.3-SNAPSHOT.jar input/schwab-export.json 2025 ./output With E-Trade data: -java -jar target/cockroach-0.2-SNAPSHOT.jar /tmp/219114411.json 2025 /tmp/taxes /tmp/e-trade-dir +java -jar target/cockroach-0.3-SNAPSHOT.jar input/schwab-export.json 2025 ./output input/etrade + +With YAML config (Schwab and/or E-Trade and/or Degiro): + +java -jar target/cockroach-0.3-SNAPSHOT.jar input/config.yaml # Converting to PDF diff --git a/media/degiro.png b/media/degiro.png new file mode 100644 index 0000000000000000000000000000000000000000..5d8dcd223481525158e273e860ed2b87d3f04589 GIT binary patch literal 31058 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 org.junit.jupiter junit-jupiter-api - 5.9.3 + 5.10.3 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.10.3 + test + + + org.junit.jupiter + junit-jupiter-params + 5.10.3 + test + + + org.junit.vintage + junit-vintage-engine + 5.10.3 test @@ -105,6 +123,11 @@ poi-ooxml 5.4.0 + + com.charleskorn.kaml + kaml-jvm + 0.61.0 + @@ -163,6 +186,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt new file mode 100644 index 0000000..8ca7a37 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt @@ -0,0 +1,126 @@ +package cz.solutions.cockroach + +import org.joda.time.LocalDate +import java.io.File +import java.net.URI +import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit +import java.util.logging.Logger + +/** + * Source of CNB exchange-rate-fixing data for a whole year. + * + * A single year may produce more than one chunk: the column layout of the + * CNB year.txt file occasionally changes mid-year (e.g. 2022 when HRK was + * removed). Each returned string is a self-contained chunk that starts with + * its own header line. + */ +interface CnbYearRatesSource { + fun loadYear(year: Int): List +} + +/** + * Reads CNB year.txt content from bundled classpath resources. + * + * Used by tests and by [TabularExchangeRateProvider.hardcoded] so that no + * network is touched during unit tests. + */ +class ClasspathCnbYearRatesSource : CnbYearRatesSource { + override fun loadYear(year: Int): List { + return when (year) { + 2022 -> listOf(loadResource("rates_2022_a.txt"), loadResource("rates_2022_b.txt")) + else -> listOf(loadResource("rates_$year.txt")) + } + } + + private fun loadResource(name: String): String { + return ClasspathCnbYearRatesSource::class.java.getResourceAsStream(name)?.use { + it.reader(StandardCharsets.UTF_8).readText() + } ?: throw IllegalStateException("classpath resource not found: $name") + } +} + +/** + * Downloads CNB year.txt from cnb.cz on first use and caches each year on + * disk under [cacheDir]. Past years are cached forever (CNB never amends + * fixings retroactively); the current year is refreshed when the cached + * copy is older than [currentYearMaxAgeMs]. + * + * When the downloaded content contains more than one header line (e.g. the + * 2022 HRK transition), it is split into multiple chunks so downstream + * parsing keeps working. + */ +class HttpCnbYearRatesSource( + private val cacheDir: File, + private val baseUrl: String = DEFAULT_BASE_URL, + private val currentYearMaxAgeMs: Long = TimeUnit.HOURS.toMillis(24), + private val today: () -> LocalDate = { LocalDate.now() } +) : CnbYearRatesSource { + + override fun loadYear(year: Int): List { + val raw = loadRawWithCache(year) + return splitByHeader(raw) + } + + private fun loadRawWithCache(year: Int): String { + if (!cacheDir.exists()) { + require(cacheDir.mkdirs() || cacheDir.exists()) { + "could not create cache directory ${cacheDir.absolutePath}" + } + } + val cacheFile = File(cacheDir, "rates_$year.txt") + val currentYear = today().year + val stale = !cacheFile.exists() || + (year >= currentYear && + System.currentTimeMillis() - cacheFile.lastModified() > currentYearMaxAgeMs) + if (stale) { + download(year, cacheFile) + } + return cacheFile.readText(StandardCharsets.UTF_8) + } + + private fun download(year: Int, target: File) { + val url = URI("$baseUrl?year=$year").toURL() + LOGGER.info("downloading CNB rates for $year from $url") + val tmp = File(target.parentFile, "${target.name}.tmp") + url.openStream().use { ins -> + tmp.outputStream().use { out -> ins.copyTo(out) } + } + if (target.exists() && !target.delete()) { + throw IllegalStateException("could not replace cached file ${target.absolutePath}") + } + if (!tmp.renameTo(target)) { + throw IllegalStateException("could not move ${tmp.absolutePath} to ${target.absolutePath}") + } + } + + private fun splitByHeader(content: String): List { + val chunks = mutableListOf>() + for (line in content.lines()) { + if (isHeaderLine(line)) { + chunks.add(mutableListOf(line)) + } else if (line.isNotBlank() && chunks.isNotEmpty()) { + chunks.last().add(line) + } + } + require(chunks.isNotEmpty()) { "no header line found in CNB response" } + return chunks.map { it.joinToString("\n") } + } + + private fun isHeaderLine(line: String): Boolean { + val first = line.substringBefore('|').trim() + return first == "Date" || first == "Datum" + } + + companion object { + private val LOGGER = Logger.getLogger(HttpCnbYearRatesSource::class.java.name) + + const val DEFAULT_BASE_URL = + "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/year.txt" + + fun defaultCacheDir(): File { + val home = System.getProperty("user.home") ?: "." + return File(home, ".cache/cockroach/rates") + } + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt new file mode 100644 index 0000000..e6d7f92 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt @@ -0,0 +1,20 @@ +package cz.solutions.cockroach + +import com.charleskorn.kaml.Yaml +import kotlinx.serialization.Serializable +import java.io.File + +@Serializable +data class CockroachConfig( + val year: Int, + val outputDir: String, + val schwab: String? = null, + val etrade: String? = null, + val degiro: List = emptyList() +) { + companion object { + fun load(file: File): CockroachConfig { + return Yaml.default.decodeFromString(serializer(), file.readText()) + } + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index 4b214ef..83d96fd 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -5,8 +5,20 @@ import java.nio.charset.StandardCharsets import java.util.logging.Logger fun main(args: Array) { + if (args.size == 1 && (args[0].endsWith(".yaml") || args[0].endsWith(".yml"))) { + val config = CockroachConfig.load(File(args[0])) + CockroachMain.report( + schwabExportFile = config.schwab?.let { File(it) }, + year = config.year, + outputDir = File(config.outputDir), + eTradeDir = config.etrade?.let { File(it) }, + degiroFiles = config.degiro.map { File(it) } + ) + return + } if (args.size < 3) { System.err.println("Usage: cockroach [etrade-dir]") + System.err.println(" cockroach ") System.err.println() System.err.println(" schwab-json-export Path to the Schwab JSON export file") System.err.println(" year Tax year (e.g. 2025)") @@ -16,6 +28,7 @@ fun main(args: Array) { System.err.println(" espp/ - ESPP purchase confirmation PDFs") System.err.println(" dividends/ - single dividends XLSX file") System.err.println(" sales/ - single Gain & Loss CSV file") + System.err.println(" config.yaml YAML config file with year, outputDir, schwab, etrade, degiro") System.exit(1) } val eTradeDir = if (args.size > 3) File(args[3]) else null @@ -25,12 +38,26 @@ fun main(args: Array) { object CockroachMain { private val LOGGER = Logger.getLogger(CockroachMain::class.java.name) - fun report(schwabExportFile: File, year: Int, outputDir: File, eTradeDir: File? = null) { - val schwabExport = parseExportFile(schwabExportFile) + fun report( + schwabExportFile: File?, + year: Int, + outputDir: File, + eTradeDir: File? = null, + degiroFiles: List = emptyList() + ) { + val schwabExport = schwabExportFile?.let { parseExportFile(it) } ?: ParsedExport.empty() val eTradeExport = eTradeDir?.let { parseETradeDir(it) } ?: ParsedExport.empty() - val parsedExport = schwabExport + eTradeExport + val degiroExport = degiroFiles.map { parseDegiroFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } + val parsedExport = schwabExport + eTradeExport + degiroExport + require(parsedExport != ParsedExport.empty()) { + "No input sources provided. Specify at least one of: schwab, etrade, degiro." + } + val dailyRateProvider = TabularExchangeRateProvider.fromSource( + HttpCnbYearRatesSource(HttpCnbYearRatesSource.defaultCacheDir()), + year..year + ) val fixedRateReport = ReportGenerator.generateForYear(parsedExport, year, YearConstantExchangeRateProvider.hardcoded()) - val dynamicRateReport = ReportGenerator.generateForYear(parsedExport, year, TabularExchangeRateProvider.hardcoded()) + val dynamicRateReport = ReportGenerator.generateForYear(parsedExport, year, dailyRateProvider) reportOneVariant(year, outputDir, fixedRateReport, "fixed") reportOneVariant(year, outputDir, dynamicRateReport, "dynamic") @@ -64,6 +91,19 @@ object CockroachMain { } } + private fun parseDegiroFile(file: File): ParsedExport { + val result = DegiroAccountStatementParser.parse(file) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = result.dividendRecords, + taxRecords = result.taxRecords, + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList() + ) + } + private fun parseETradeDir(eTradeDir: File): ParsedExport { val rsuRecords = RsuPdfParser.parseDirectory(File(eTradeDir, "rsu")) val esppRecords = EsppPdfParser.parseDirectory(File(eTradeDir, "espp")) diff --git a/src/main/kotlin/cz/solutions/cockroach/Currency.kt b/src/main/kotlin/cz/solutions/cockroach/Currency.kt new file mode 100644 index 0000000..1cf8f74 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/Currency.kt @@ -0,0 +1,5 @@ +package cz.solutions.cockroach + +enum class Currency { + USD, EUR, GBP, CZK +} diff --git a/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt new file mode 100644 index 0000000..419c443 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt @@ -0,0 +1,116 @@ +package cz.solutions.cockroach + +import org.apache.poi.ss.usermodel.Cell +import org.apache.poi.ss.usermodel.CellType +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.usermodel.WorkbookFactory +import org.joda.time.LocalDate +import org.joda.time.format.DateTimeFormat +import java.io.File +import java.io.InputStream +import java.util.logging.Logger + +data class DegiroParseResult( + val dividendRecords: List, + val taxRecords: List +) + +object DegiroAccountStatementParser { + + private val LOGGER = Logger.getLogger(DegiroAccountStatementParser::class.java.name) + + private val DATE_FORMATTER = DateTimeFormat.forPattern("dd-MM-yyyy") + + private const val SHEET_NAME = "Přehled účtu" + + private const val DESC_DIVIDEND = "Dividenda" + private const val DESC_TAX = "Daň z dividendy" + private const val DESC_ADR_FEE = "ADR/GDR Pass-Through poplatek" + + private const val COL_VALUE_DATE = 2 + private const val COL_DESCRIPTION = 5 + private const val COL_CURRENCY = 7 + private const val COL_AMOUNT = 8 + + fun parse(file: File): DegiroParseResult { + return file.inputStream().use { parse(it) } + } + + fun parse(inputStream: InputStream): DegiroParseResult { + return WorkbookFactory.create(inputStream).use { parse(it) } + } + + fun parse(workbook: Workbook): DegiroParseResult { + val sheet = workbook.getSheet(SHEET_NAME) + ?: throw IllegalArgumentException("Sheet '$SHEET_NAME' not found in Degiro statement") + + val dividends = mutableListOf() + val taxes = mutableListOf() + var ignoredAdrCount = 0 + var ignoredAdrTotal = 0.0 + var ignoredAdrCurrency: String? = null + + for (i in 1..sheet.lastRowNum) { + val row = sheet.getRow(i) ?: continue + val description = stringCell(row, COL_DESCRIPTION) ?: continue + when (description.trim()) { + DESC_DIVIDEND -> { + val record = parseRecord(row) ?: continue + dividends.add(DividendRecord(record.date, record.amount, record.currency)) + } + DESC_TAX -> { + val record = parseRecord(row) ?: continue + taxes.add(TaxRecord(record.date, record.amount, record.currency)) + } + DESC_ADR_FEE -> { + val record = parseRecord(row) ?: continue + ignoredAdrCount++ + ignoredAdrTotal += record.amount + ignoredAdrCurrency = record.currency.name + } + } + } + + if (ignoredAdrCount > 0) { + LOGGER.info("Ignored $ignoredAdrCount ADR/GDR Pass-Through fee row(s) totalling $ignoredAdrTotal $ignoredAdrCurrency (not a withholding tax).") + } + + return DegiroParseResult(dividends, taxes) + } + + private data class ParsedRow(val date: LocalDate, val amount: Double, val currency: Currency) + + private fun parseRecord(row: Row): ParsedRow? { + val dateStr = stringCell(row, COL_VALUE_DATE) ?: return null + val currencyStr = stringCell(row, COL_CURRENCY) ?: return null + val amountStr = stringCell(row, COL_AMOUNT) ?: return null + + val date = LocalDate.parse(dateStr.trim(), DATE_FORMATTER) + val currency = try { + Currency.valueOf(currencyStr.trim()) + } catch (e: IllegalArgumentException) { + LOGGER.warning("Unknown currency '$currencyStr' on row ${row.rowNum + 1}, skipping") + return null + } + val amount = parseAmount(amountStr) ?: return null + return ParsedRow(date, amount, currency) + } + + private fun parseAmount(input: String): Double? { + val cleaned = input.trim() + .replace("\u00a0", "") + .replace(" ", "") + .replace(",", ".") + return cleaned.toDoubleOrNull() + } + + private fun stringCell(row: Row, index: Int): String? { + val cell: Cell = row.getCell(index) ?: return null + return when (cell.cellType) { + CellType.STRING -> cell.stringCellValue.takeIf { it.isNotBlank() } + CellType.NUMERIC -> cell.numericCellValue.toString() + else -> null + } + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt index eb9705a..9870131 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt @@ -4,5 +4,6 @@ import org.joda.time.LocalDate data class DividendRecord( val date: LocalDate, - val amount: Double + val amount: Double, + val currency: Currency = Currency.USD ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendReport.kt b/src/main/kotlin/cz/solutions/cockroach/DividendReport.kt index a2dcaeb..c47ba85 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendReport.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendReport.kt @@ -1,23 +1,27 @@ package cz.solutions.cockroach -data class DividendReport( +data class CurrencyDividendSection( + val currency: Currency, val printableDividendList: List, - val totalBruttoDollar: Double, - val totalTaxDollar: Double, + val totalBrutto: Double, + val totalTax: Double, val totalBruttoCrown: Double, val totalTaxCrown: Double, - val totalTaxReversalDollar: Double, + val totalTaxReversal: Double, val totalTaxReversalCrown: Double +) + +data class CzkDividendSection( + val printableDividendList: List, + val totalBruttoCrown: Double, + val totalTaxCrown: Double, + val totalTaxReversalCrown: Double +) + +data class DividendReport( + val sections: List, + val czkSection: CzkDividendSection? ) { - fun asMap(): Map { - return mapOf( - "dividendList" to printableDividendList, - "totalBruttoDollar" to FormatingHelper.formatDouble(totalBruttoDollar), - "totalTaxDollar" to FormatingHelper.formatDouble(totalTaxDollar), - "totalBruttoCrown" to FormatingHelper.formatDouble(totalBruttoCrown), - "totalTaxCrown" to FormatingHelper.formatDouble(totalTaxCrown), - "totalTaxReversal" to if (totalTaxReversalDollar > 0) FormatingHelper.formatDouble(totalTaxReversalDollar) else "", - "totalTaxReversalCrown" to if (totalTaxReversalCrown > 0) FormatingHelper.formatDouble(totalTaxReversalCrown) else "" - ) - } + val totalNonCzkBruttoCrown: Double get() = sections.sumOf { it.totalBruttoCrown } + val totalNonCzkTaxCrown: Double get() = sections.sumOf { it.totalTaxCrown } } \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt index 1278e90..3de98a0 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt @@ -1,38 +1,93 @@ package cz.solutions.cockroach +import org.apache.pdfbox.io.IOUtils +import org.apache.pdfbox.io.RandomAccessReadBuffer +import org.apache.pdfbox.multipdf.PDFMergerUtility +import java.io.ByteArrayOutputStream + object DividendReportPdfGenerator { fun generate(report: DividendReport): ByteArray { - val columns = listOf( - PdfColumn("Datum", 1f), PdfColumn("Brutto (USD)", 1f), PdfColumn("Sražená daň (USD)", 1f), - PdfColumn("Kurz D54 (Kč/USD)", 1f), PdfColumn("Brutto (Kč)", 1f), PdfColumn("Sražená daň (Kč)", 1f) - ) + val pdfs = mutableListOf() + for (section in report.sections) { + pdfs.add(generateCurrencySectionPdf(section)) + } + report.czkSection?.let { pdfs.add(generateCzkSectionPdf(it)) } - val rows = report.printableDividendList.map { d -> - listOf(d.date, d.bruttoDollar, d.taxDollar, d.exchange, d.bruttoCrown, d.taxCrown) + if (pdfs.isEmpty()) { + return PdfReportGenerator.generate(PdfReportDefinition( + title = "Dividendy (§8) – rozpis", + subtitles = listOf("Žádné dividendy v daném období."), + columns = listOf(PdfColumn("Datum", 1f)), + rows = emptyList(), + landscape = false + )) } + if (pdfs.size == 1) return pdfs[0] + return mergePdfs(pdfs) + } + private fun generateCurrencySectionPdf(section: CurrencyDividendSection): ByteArray { + val cur = section.currency.name + val columns = listOf( + PdfColumn("Datum", 1f), PdfColumn("Brutto ($cur)", 1f), PdfColumn("Sražená daň ($cur)", 1f), + PdfColumn("Kurz (Kč/$cur)", 1f), PdfColumn("Brutto (Kč)", 1f), PdfColumn("Sražená daň (Kč)", 1f) + ) + val rows = section.printableDividendList.map { d -> + listOf(d.date, d.brutto, d.tax, d.exchange, d.bruttoCrown, d.taxCrown) + } val fmt = FormatingHelper::formatDouble val summaryRow = listOf( - SummaryCell.bold("Celkem"), // Datum - SummaryCell.bold(fmt(report.totalBruttoDollar)), // Brutto (USD) - SummaryCell.bold(fmt(report.totalTaxDollar)), // Sražená daň (USD) - SummaryCell.empty(), // Kurz D54 - SummaryCell.bold(fmt(report.totalBruttoCrown)), // Brutto (Kč) - SummaryCell.bold(fmt(report.totalTaxCrown)) // Sražená daň (Kč) + SummaryCell.bold("Celkem"), + SummaryCell.bold(fmt(section.totalBrutto)), + SummaryCell.bold(fmt(section.totalTax)), + SummaryCell.empty(), + SummaryCell.bold(fmt(section.totalBruttoCrown)), + SummaryCell.bold(fmt(section.totalTaxCrown)) ) - val footerLines = mutableListOf() - if (report.totalTaxReversalDollar > 0) { - footerLines.add("Vrácená daň (tax reversal): ${fmt(report.totalTaxReversalCrown)} CZK (${fmt(report.totalTaxReversalDollar)} USD)") + if (section.totalTaxReversal > 0) { + footerLines.add("Vrácená daň (tax reversal): ${fmt(section.totalTaxReversalCrown)} CZK (${fmt(section.totalTaxReversal)} $cur)") } + return PdfReportGenerator.generate(PdfReportDefinition( + title = "Dividendy (§8) – rozpis – $cur", + subtitles = listOf("Měna zdroje: $cur"), + columns = columns, rows = rows, summaryRow = summaryRow, footerLines = footerLines, + landscape = false + )) + } + private fun generateCzkSectionPdf(section: CzkDividendSection): ByteArray { + val columns = listOf( + PdfColumn("Datum", 1f), PdfColumn("Brutto (Kč)", 1f), PdfColumn("Sražená daň (Kč)", 1f) + ) + val rows = section.printableDividendList.map { d -> listOf(d.date, d.brutto, d.tax) } + val fmt = FormatingHelper::formatDouble + val summaryRow = listOf( + SummaryCell.bold("Celkem"), + SummaryCell.bold(fmt(section.totalBruttoCrown)), + SummaryCell.bold(fmt(section.totalTaxCrown)) + ) + val footerLines = mutableListOf() + if (section.totalTaxReversalCrown > 0) { + footerLines.add("Vrácená daň (tax reversal): ${fmt(section.totalTaxReversalCrown)} CZK") + } return PdfReportGenerator.generate(PdfReportDefinition( - title = "Dividendy (§8) – rozpis", - subtitles = listOf("Cenný papír: Cisco Systems", "Stát zdroje příjmů: USA", "Obchodník: Charles Schwab & Co., Morgan Stanley & Co."), + title = "Dividendy ze zdrojů v ČR – rozpis", + subtitles = listOf("Měna zdroje: CZK"), columns = columns, rows = rows, summaryRow = summaryRow, footerLines = footerLines, landscape = false )) } + + private fun mergePdfs(pdfs: List): ByteArray { + val merger = PDFMergerUtility() + val out = ByteArrayOutputStream() + merger.destinationStream = out + for (pdf in pdfs) merger.addSource(RandomAccessReadBuffer(pdf)) + merger.mergeDocuments(IOUtils.createMemoryOnlyStreamCache()) + return out.toByteArray() + } } + diff --git a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt index 98bb71f..3064d4e 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt @@ -15,39 +15,66 @@ object DividentReportPreparation { exchangeRateProvider: ExchangeRateProvider ): DividendReport { - val dividendRecords = dividendRecordList - .filter { interval.contains(it.date) } - .sortedBy { it.date } + val dividendsInInterval = dividendRecordList.filter { interval.contains(it.date) } + val taxesInInterval = taxRecordList.filter { interval.contains(it.date) } + val reversalsInInterval = taxReversalRecordList.filter { interval.contains(it.date) } - val taxRecords = taxRecordList - .filter { interval.contains(it.date) } - .groupBy({ it.date }) { it } - .mapValues { it.value.toMutableList() } + val dividendsByCurrency = dividendsInInterval.groupBy { it.currency } + val taxesByCurrency = taxesInInterval.groupBy { it.currency } + val reversalsByCurrency = reversalsInInterval.groupBy { it.currency } - val taxReversalRecords = taxReversalRecordList - .filter { interval.contains(it.date) } - .sortedBy { it.date } + val nonCzkCurrencies = (dividendsByCurrency.keys + taxesByCurrency.keys + reversalsByCurrency.keys) + .filter { it != Currency.CZK } + .sortedBy { it.name } - val printableDividendList = mutableListOf() + val sections = nonCzkCurrencies.map { currency -> + buildCurrencySection( + currency, + dividendsByCurrency[currency].orEmpty(), + taxesByCurrency[currency].orEmpty(), + reversalsByCurrency[currency].orEmpty(), + exchangeRateProvider + ) + } + + val czkSection = if (Currency.CZK in dividendsByCurrency.keys || Currency.CZK in taxesByCurrency.keys || Currency.CZK in reversalsByCurrency.keys) { + buildCzkSection( + dividendsByCurrency[Currency.CZK].orEmpty(), + taxesByCurrency[Currency.CZK].orEmpty(), + reversalsByCurrency[Currency.CZK].orEmpty() + ) + } else null + + return DividendReport(sections, czkSection) + } - var totalBruttoDollar = 0.0 - var totalTaxDollar = 0.0 + private fun buildCurrencySection( + currency: Currency, + dividendRecords: List, + taxRecords: List, + reversalRecords: List, + exchangeRateProvider: ExchangeRateProvider + ): CurrencyDividendSection { + val sortedDividends = dividendRecords.sortedBy { it.date } + val taxesByDate = taxRecords.groupBy({ it.date }) { it }.mapValues { it.value.toMutableList() } + val printable = mutableListOf() + var totalBrutto = 0.0 + var totalTax = 0.0 var totalBruttoCrown = 0.0 var totalTaxCrown = 0.0 - for (dividendRecord in dividendRecords) { - val exchange = exchangeRateProvider.rateAt(dividendRecord.date) - val taxCandidates = taxRecords[dividendRecord.date] - val taxRecord = taxCandidates?.minByOrNull { abs( abs(it.amount) - abs(dividendRecord.amount)*0.15 )} //if there were more taxes on the same day, we take the one closest to 15% of dividend amount, because that's the most likely correct one + for (dividendRecord in sortedDividends) { + val exchange = exchangeRateProvider.rateAt(dividendRecord.date, currency) + val taxCandidates = taxesByDate[dividendRecord.date] + val taxRecord = taxCandidates?.minByOrNull { abs(abs(it.amount) - abs(dividendRecord.amount) * 0.15) } //if there were more taxes on the same day, we take the one closest to 15% of dividend amount, because that's the most likely correct one if (taxRecord != null) taxCandidates.remove(taxRecord) - if (taxRecord != null) { - totalBruttoDollar += dividendRecord.amount - totalTaxDollar += taxRecord.amount + totalBrutto += dividendRecord.amount + totalTax += taxRecord.amount totalBruttoCrown += dividendRecord.amount * exchange totalTaxCrown += taxRecord.amount * exchange - printableDividendList.add( + printable.add( PrintableDividend( DATE_FORMATTER.print(dividendRecord.date), FormatingHelper.formatDouble(dividendRecord.amount), @@ -60,17 +87,41 @@ object DividentReportPreparation { } } - val totalTaxReversalDollar = taxReversalRecords.sumOf { it.amount } - val totalTaxReversalCrown = taxReversalRecords.sumOf { it.amount * exchangeRateProvider.rateAt(it.date) } - - return DividendReport( - printableDividendList, - totalBruttoDollar, - totalTaxDollar, - totalBruttoCrown, - totalTaxCrown, - totalTaxReversalDollar, - totalTaxReversalCrown - ) + val totalTaxReversal = reversalRecords.sumOf { it.amount } + val totalTaxReversalCrown = reversalRecords.sumOf { it.amount * exchangeRateProvider.rateAt(it.date, currency) } + + return CurrencyDividendSection(currency, printable, totalBrutto, totalTax, totalBruttoCrown, totalTaxCrown, totalTaxReversal, totalTaxReversalCrown) + } + + private fun buildCzkSection( + dividendRecords: List, + taxRecords: List, + reversalRecords: List + ): CzkDividendSection { + val sortedDividends = dividendRecords.sortedBy { it.date } + val taxesByDate = taxRecords.groupBy({ it.date }) { it }.mapValues { it.value.toMutableList() } + val printable = mutableListOf() + var totalBruttoCrown = 0.0 + var totalTaxCrown = 0.0 + + for (dividendRecord in sortedDividends) { + val taxCandidates = taxesByDate[dividendRecord.date] + val taxRecord = taxCandidates?.minByOrNull { abs(abs(it.amount) - abs(dividendRecord.amount) * 0.15) } + if (taxRecord != null) taxCandidates.remove(taxRecord) + if (taxRecord != null) { + totalBruttoCrown += dividendRecord.amount + totalTaxCrown += taxRecord.amount + printable.add( + PrintableCzkDividend( + DATE_FORMATTER.print(dividendRecord.date), + FormatingHelper.formatDouble(dividendRecord.amount), + FormatingHelper.formatDouble(taxRecord.amount) + ) + ) + } + } + + val totalTaxReversalCrown = reversalRecords.sumOf { it.amount } + return CzkDividendSection(printable, totalBruttoCrown, totalTaxCrown, totalTaxReversalCrown) } } \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt index ccac49e..416d5d3 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt @@ -38,7 +38,7 @@ object EsppReportPreparation { } private fun withConvertedPrices(espp: EsppRecord, soldAmount: Double, taxableAmount: Double, exchangeRateProvider: ExchangeRateProvider): EsppInfo { - val exchange = exchangeRateProvider.rateAt(espp.purchaseDate) + val exchange = exchangeRateProvider.rateAt(espp.purchaseDate, Currency.USD) val partialProfit = espp.purchaseFmv - espp.purchasePrice return EsppInfo( diff --git a/src/main/kotlin/cz/solutions/cockroach/ExchangeRateProvider.kt b/src/main/kotlin/cz/solutions/cockroach/ExchangeRateProvider.kt index b795960..2bc94ce 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ExchangeRateProvider.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ExchangeRateProvider.kt @@ -3,5 +3,5 @@ package cz.solutions.cockroach import org.joda.time.LocalDate fun interface ExchangeRateProvider { - fun rateAt(day: LocalDate): Double + fun rateAt(day: LocalDate, currency: Currency): Double } \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/ExchangeRatesReader.kt b/src/main/kotlin/cz/solutions/cockroach/ExchangeRatesReader.kt index 51ca5cf..a103a84 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ExchangeRatesReader.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ExchangeRatesReader.kt @@ -5,34 +5,39 @@ import org.joda.time.format.DateTimeFormat object ExchangeRatesReader { + private val CURRENCY_HEADERS = mapOf( + Currency.USD to "1 USD", + Currency.EUR to "1 EUR", + Currency.GBP to "1 GBP" + ) + fun parse(vararg files: String): TabularExchangeRateProvider { val mapping = files.map { parseOne(it) } .reduce { acc, map -> acc + map } return TabularExchangeRateProvider(mapping) } - private fun parseOne(data: String): Map { + private fun parseOne(data: String): Map> { val lines = data.lines() val header = lines.first() val headerParts = header.split("|") - val usdIndex = headerParts.indexOf("1 USD") + val indices = CURRENCY_HEADERS.mapValues { (currency, columnHeader) -> + val idx = headerParts.indexOf(columnHeader) + require(idx >= 0) { "missing $columnHeader column for $currency in header: $header" } + idx + } return lines .drop(1) .filter { it.isNotBlank() } - .map { parseLine(it, usdIndex) } - .associate { it.date to it.getAmount() } + .associate { parseLine(it, indices) } } - private fun parseLine(line: String, usdIndex: Int): Line { + private fun parseLine(line: String, indices: Map): Pair> { val formatter = DateTimeFormat.forPattern("dd.MM.YYYY") val parts = line.split("|") - return Line(LocalDate.parse(parts[0], formatter), Money.fromString(parts[usdIndex])) - } - - private data class Line(val date: LocalDate, val rate: Money) { - fun getAmount(): Double { - return rate.amount - } + val date = LocalDate.parse(parts[0], formatter) + val rates = indices.mapValues { (_, idx) -> Money.fromString(parts[idx]).amount } + return date to rates } private data class Money(val amount: Double) { diff --git a/src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt b/src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt new file mode 100644 index 0000000..2428483 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt @@ -0,0 +1,7 @@ +package cz.solutions.cockroach + +data class PrintableCzkDividend( + val date: String, + val brutto: String, + val tax: String +) diff --git a/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt b/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt index 8bd8e12..56effbd 100644 --- a/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt @@ -2,9 +2,9 @@ package cz.solutions.cockroach data class PrintableDividend( val date: String, - val bruttoDollar: String, + val brutto: String, val exchange: String, - val taxDollar: String, + val tax: String, val bruttoCrown: String, val taxCrown: String ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/Report.kt b/src/main/kotlin/cz/solutions/cockroach/Report.kt index 88e2728..174fab8 100644 --- a/src/main/kotlin/cz/solutions/cockroach/Report.kt +++ b/src/main/kotlin/cz/solutions/cockroach/Report.kt @@ -49,8 +49,8 @@ class Report( "taxableSellProfitCroneValue" to FormatingHelper.formatRounded(salesReport.profitForTax) ) val dividentVars = mapOf( - "dividendCroneValue" to FormatingHelper.formatRounded(dividendReport.totalBruttoCrown), - "dividendPayedTaxCroneValue" to FormatingHelper.formatRounded(-dividendReport.totalTaxCrown) + "dividendCroneValue" to FormatingHelper.formatRounded(dividendReport.totalNonCzkBruttoCrown), + "dividendPayedTaxCroneValue" to FormatingHelper.formatRounded(-dividendReport.totalNonCzkTaxCrown) ) val variables = rsuAndEsppVars + salesVars + dividentVars diff --git a/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt index bb467e2..6835f03 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt @@ -38,7 +38,7 @@ object RsuReportPreparation { } private fun withConvertedPrices(rsu: RsuRecord, soldAmount: Double, taxableAmount: Double,exchangeRateProvider: ExchangeRateProvider): RsuInfo { - val exchange = exchangeRateProvider.rateAt(rsu.vestDate) + val exchange = exchangeRateProvider.rateAt(rsu.vestDate, Currency.USD) val partialRsuDolarValue = rsu.quantity * rsu.vestFmv val partialRsuCroneValue = partialRsuDolarValue * exchange val taxableVestCroneValue = taxableAmount * rsu.vestFmv * exchange diff --git a/src/main/kotlin/cz/solutions/cockroach/SalesReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/SalesReportPreparation.kt index e73a902..3d11224 100644 --- a/src/main/kotlin/cz/solutions/cockroach/SalesReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/SalesReportPreparation.kt @@ -23,8 +23,8 @@ object SalesReportPreparation { var totalAmount = 0.0 val printableSalesList = filteredSaleRecords.map { sale -> - val sellExchange = exchangeRateProvider.rateAt(sale.date) - val buyExchange = exchangeRateProvider.rateAt(sale.purchaseDate) + val sellExchange = exchangeRateProvider.rateAt(sale.date, Currency.USD) + val buyExchange = exchangeRateProvider.rateAt(sale.purchaseDate, Currency.USD) val partialSellDolarValue = sale.quantity * sale.salePrice val partialSellCroneValue = partialSellDolarValue * sellExchange diff --git a/src/main/kotlin/cz/solutions/cockroach/TabularExchangeRateProvider.kt b/src/main/kotlin/cz/solutions/cockroach/TabularExchangeRateProvider.kt index cce84b7..8ad9424 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TabularExchangeRateProvider.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TabularExchangeRateProvider.kt @@ -1,33 +1,29 @@ package cz.solutions.cockroach import org.joda.time.LocalDate -import java.nio.charset.StandardCharsets import java.util.* -class TabularExchangeRateProvider(knownRates: Map) : ExchangeRateProvider { - private val knownRates: NavigableMap = TreeMap(knownRates) +class TabularExchangeRateProvider( + knownRates: Map> +) : ExchangeRateProvider { + private val knownRates: NavigableMap> = TreeMap(knownRates) companion object { fun hardcoded(): TabularExchangeRateProvider { - return ExchangeRatesReader.parse( - load("rates_2021.txt"), - load("rates_2022_a.txt"), - load("rates_2022_b.txt"), - load("rates_2023.txt"), - load("rates_2024.txt"), - load("rates_2025.txt") - ) + return fromSource(ClasspathCnbYearRatesSource(), 2021..2025) } - private fun load(fileName: String): String { - return TabularExchangeRateProvider::class.java.getResourceAsStream(fileName)?.use { - it.reader(StandardCharsets.UTF_8).readText() - } ?: throw RuntimeException("Could not load template $fileName") + fun fromSource(source: CnbYearRatesSource, years: IntRange): TabularExchangeRateProvider { + val chunks = years.flatMap { source.loadYear(it) } + return ExchangeRatesReader.parse(*chunks.toTypedArray()) } } - override fun rateAt(day: LocalDate): Double { - return knownRates.floorEntry(day)?.value + override fun rateAt(day: LocalDate, currency: Currency): Double { + if (currency == Currency.CZK) return 1.0 + val perCurrency = knownRates.floorEntry(day)?.value ?: throw IllegalArgumentException("can not find rate for $day") + return perCurrency[currency] + ?: throw IllegalArgumentException("can not find rate for $day in $currency") } } \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt b/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt index 7ad7735..4d2d477 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt @@ -4,5 +4,6 @@ import org.joda.time.LocalDate data class TaxRecord( val date: LocalDate, - val amount: Double + val amount: Double, + val currency: Currency = Currency.USD ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt b/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt index d36a738..267b6cc 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt @@ -4,5 +4,6 @@ import org.joda.time.LocalDate data class TaxReversalRecord( val date: LocalDate, - val amount: Double + val amount: Double, + val currency: Currency = Currency.USD ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/YearConstantExchangeRateProvider.kt b/src/main/kotlin/cz/solutions/cockroach/YearConstantExchangeRateProvider.kt index de03dff..afcbd82 100644 --- a/src/main/kotlin/cz/solutions/cockroach/YearConstantExchangeRateProvider.kt +++ b/src/main/kotlin/cz/solutions/cockroach/YearConstantExchangeRateProvider.kt @@ -2,26 +2,58 @@ package cz.solutions.cockroach import org.joda.time.LocalDate -class YearConstantExchangeRateProvider(private val exchange: Map) : ExchangeRateProvider { +class YearConstantExchangeRateProvider( + private val exchange: Map> +) : ExchangeRateProvider { companion object { + fun usdOnly(map: Map): YearConstantExchangeRateProvider { + return YearConstantExchangeRateProvider( + map.mapValues { (_, rate) -> mapOf(Currency.USD to rate) } + ) + } + fun hardcoded(): YearConstantExchangeRateProvider { return YearConstantExchangeRateProvider( mapOf( - 2018 to 21.780, - 2019 to 22.930, - 2020 to 23.140, - 2021 to 21.72, - 2022 to 23.41, - 2023 to 22.14, - 2024 to 23.28, - 2025 to 21.84 + 2018 to mapOf(Currency.USD to 21.780), + 2019 to mapOf(Currency.USD to 22.930), + 2020 to mapOf(Currency.USD to 23.140), + 2021 to mapOf( + Currency.USD to 21.72, + Currency.EUR to 25.65, + Currency.GBP to 29.88 + ), + 2022 to mapOf( + Currency.USD to 23.41, + Currency.EUR to 24.54, + Currency.GBP to 28.72 + ), + 2023 to mapOf( + Currency.USD to 22.14, + Currency.EUR to 23.97, + Currency.GBP to 27.59 + ), + 2024 to mapOf( + Currency.USD to 23.28, + Currency.EUR to 25.16, + Currency.GBP to 29.78 + ), + 2025 to mapOf( + Currency.USD to 21.84, + Currency.EUR to 24.66, + Currency.GBP to 28.80 + ) ) ) } } - override fun rateAt(day: LocalDate): Double { - return exchange[day.year] ?: throw IllegalArgumentException("can not find rate for $day") + override fun rateAt(day: LocalDate, currency: Currency): Double { + if (currency == Currency.CZK) return 1.0 + val perCurrency = exchange[day.year] + ?: throw IllegalArgumentException("can not find rate for $day") + return perCurrency[currency] + ?: throw IllegalArgumentException("can not find rate for $day in $currency") } } \ No newline at end of file diff --git a/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt new file mode 100644 index 0000000..f028bec --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt @@ -0,0 +1,121 @@ +package cz.solutions.cockroach + +import org.apache.poi.hssf.usermodel.HSSFWorkbook +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.assertj.core.api.Assertions.assertThat +import org.joda.time.LocalDate +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import java.io.File + +class DegiroAccountStatementParserTest { + + enum class Format(val extension: String, val newWorkbook: () -> Workbook) { + XLS("xls", { HSSFWorkbook() }), + XLSX("xlsx", { XSSFWorkbook() }) + } + + private data class Row( + val date: String, + val time: String, + val valueDate: String, + val product: String, + val isin: String, + val description: String, + val currency: String, + val amount: String + ) + + @ParameterizedTest + @EnumSource(Format::class) + fun parsesDividendAndTaxRecords(format: Format, @TempDir tempDir: File) { + val file = File(tempDir, "degiro.${format.extension}") + createWorkbook(format, file, listOf( + Row("15-03-2024", "10:00", "15-03-2024", "APPLE INC", "US0378331005", "Dividenda", "USD", "12,34"), + Row("15-03-2024", "10:00", "15-03-2024", "APPLE INC", "US0378331005", "Daň z dividendy", "USD", "-1,85") + )) + + val result = DegiroAccountStatementParser.parse(file) + + assertThat(result.dividendRecords).containsExactly( + DividendRecord(LocalDate(2024, 3, 15), 12.34, Currency.USD) + ) + assertThat(result.taxRecords).containsExactly( + TaxRecord(LocalDate(2024, 3, 15), -1.85, Currency.USD) + ) + } + + @ParameterizedTest + @EnumSource(Format::class) + fun usesValueDateNotBookingDate(format: Format, @TempDir tempDir: File) { + val file = File(tempDir, "degiro.${format.extension}") + createWorkbook(format, file, listOf( + Row("16-03-2024", "10:00", "15-03-2024", "APPLE INC", "US0378331005", "Dividenda", "USD", "10,00") + )) + + val result = DegiroAccountStatementParser.parse(file) + + assertThat(result.dividendRecords).containsExactly( + DividendRecord(LocalDate(2024, 3, 15), 10.00, Currency.USD) + ) + } + + @ParameterizedTest + @EnumSource(Format::class) + fun parsesEurAndCzkCurrencies(format: Format, @TempDir tempDir: File) { + val file = File(tempDir, "degiro.${format.extension}") + createWorkbook(format, file, listOf( + Row("01-04-2024", "10:00", "01-04-2024", "ASML HOLDING", "NL0010273215", "Dividenda", "EUR", "5,00"), + Row("02-04-2024", "10:00", "02-04-2024", "CEZ AS", "CZ0005112300", "Dividenda", "CZK", "1 234,56") + )) + + val result = DegiroAccountStatementParser.parse(file) + + assertThat(result.dividendRecords).containsExactly( + DividendRecord(LocalDate(2024, 4, 1), 5.00, Currency.EUR), + DividendRecord(LocalDate(2024, 4, 2), 1234.56, Currency.CZK) + ) + } + + @ParameterizedTest + @EnumSource(Format::class) + fun ignoresAdrFeesAndUnrelatedRows(format: Format, @TempDir tempDir: File) { + val file = File(tempDir, "degiro.${format.extension}") + createWorkbook(format, file, listOf( + Row("10-05-2024", "10:00", "10-05-2024", "APPLE INC", "US0378331005", "Dividenda", "USD", "20,00"), + Row("11-05-2024", "10:00", "11-05-2024", "ADR ON ALIBABA", "US01609W1027", "ADR/GDR Pass-Through poplatek", "USD", "-0,05"), + Row("12-05-2024", "10:00", "12-05-2024", "", "", "FX vyučtování konverze měny", "USD", "-1,00"), + Row("13-05-2024", "10:00", "13-05-2024", "", "", "Vklad", "EUR", "100,00") + )) + + val result = DegiroAccountStatementParser.parse(file) + + assertThat(result.dividendRecords).hasSize(1) + assertThat(result.taxRecords).isEmpty() + } + + private fun createWorkbook(format: Format, targetFile: File, rows: List) { + format.newWorkbook().use { workbook -> + val sheet = workbook.createSheet("Přehled účtu") + val header = sheet.createRow(0) + listOf("Datum", "Čas", "Datum (valuty)", "Produkt", "ISIN", "Popis", + "Kurz", "Pohyb-currency", "Pohyb-amount", "Zůstatek-currency", + "Zůstatek-amount", "ID objednávky") + .forEachIndexed { i, name -> header.createCell(i).setCellValue(name) } + rows.forEachIndexed { index, r -> + val row = sheet.createRow(index + 1) + row.createCell(0).setCellValue(r.date) + row.createCell(1).setCellValue(r.time) + row.createCell(2).setCellValue(r.valueDate) + row.createCell(3).setCellValue(r.product) + row.createCell(4).setCellValue(r.isin) + row.createCell(5).setCellValue(r.description) + row.createCell(7).setCellValue(r.currency) + row.createCell(8).setCellValue(r.amount) + } + targetFile.outputStream().use { workbook.write(it) } + } + } +} diff --git a/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt index f08f3a2..c5f2b04 100644 --- a/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt @@ -33,7 +33,7 @@ class SalesReportPreparationTest { ) ), DateInterval.year(2021), - YearConstantExchangeRateProvider(mapOf( + YearConstantExchangeRateProvider.usdOnly(mapOf( 2017 to 10.0, 2018 to 10.0, 2019 to 10.0, @@ -71,7 +71,7 @@ class SalesReportPreparationTest { ) ), DateInterval.year(2021), - YearConstantExchangeRateProvider(mapOf( + YearConstantExchangeRateProvider.usdOnly(mapOf( 2021 to 10.0, 2020 to 10.0 )) diff --git a/src/test/kotlin/cz/solutions/cockroach/TabularExchangeRateProviderTest.kt b/src/test/kotlin/cz/solutions/cockroach/TabularExchangeRateProviderTest.kt index 2b6400b..d3d7527 100644 --- a/src/test/kotlin/cz/solutions/cockroach/TabularExchangeRateProviderTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/TabularExchangeRateProviderTest.kt @@ -10,10 +10,10 @@ internal class TabularExchangeRateProviderTest { @Test fun `can parse hardcoded`() { val rateProvider = TabularExchangeRateProvider.hardcoded() - assertThat(rateProvider.rateAt(LocalDate.parse("2021-05-16")), `is`(21.024)) - assertThat(rateProvider.rateAt(LocalDate.parse("2021-05-14")), `is`(21.024)) - assertThat(rateProvider.rateAt(LocalDate.parse("2022-05-15")), `is`(23.825)) - assertThat(rateProvider.rateAt(LocalDate.parse("2023-05-15")), `is`(21.671)) - assertThat(rateProvider.rateAt(LocalDate.parse("2024-05-15")), `is`(22.861)) + assertThat(rateProvider.rateAt(LocalDate.parse("2021-05-16"), Currency.USD), `is`(21.024)) + assertThat(rateProvider.rateAt(LocalDate.parse("2021-05-14"), Currency.USD), `is`(21.024)) + assertThat(rateProvider.rateAt(LocalDate.parse("2022-05-15"), Currency.USD), `is`(23.825)) + assertThat(rateProvider.rateAt(LocalDate.parse("2023-05-15"), Currency.USD), `is`(21.671)) + assertThat(rateProvider.rateAt(LocalDate.parse("2024-05-15"), Currency.USD), `is`(22.861)) } } \ No newline at end of file From a6ce37affe7cfcf1767951c960a9c056db286fab Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 14:41:34 +0200 Subject: [PATCH 02/31] revolut support (+ interest rates) --- README.md | 81 ++++++++-- .../cz/solutions/cockroach/CockroachConfig.kt | 8 + .../cz/solutions/cockroach/CockroachMain.kt | 61 ++++++-- .../cz/solutions/cockroach/InterestRecord.kt | 9 ++ .../cz/solutions/cockroach/InterestReport.kt | 34 ++++ .../cockroach/InterestReportPdfGenerator.kt | 80 ++++++++++ .../cockroach/InterestReportPreparation.kt | 75 +++++++++ .../solutions/cockroach/JsonExportParser.kt | 15 +- .../cz/solutions/cockroach/ParsedExport.kt | 9 +- .../kotlin/cz/solutions/cockroach/Report.kt | 8 +- .../cz/solutions/cockroach/ReportGenerator.kt | 5 + .../cz/solutions/cockroach/RevolutParser.kt | 148 ++++++++++++++++++ .../cz/solutions/cockroach/guide.html.hbs | 11 ++ .../InterestReportPreparationTest.kt | 91 +++++++++++ .../solutions/cockroach/RevolutParserTest.kt | 109 +++++++++++++ 15 files changed, 712 insertions(+), 32 deletions(-) create mode 100644 src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/InterestReport.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt create mode 100644 src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt create mode 100644 src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt diff --git a/README.md b/README.md index 635f0d7..fdf9dfe 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Cockroach will help you with your taxes This small utility is for people using [Charles Schwab brokerage](https://www.schwab.com/), -[E-Trade](https://www.etrade.com/) and/or [Degiro](https://www.degiro.com/) services in the +[E-Trade](https://www.etrade.com/), [Degiro](https://www.degiro.com/) and/or +[Revolut](https://www.revolut.com/) services in the [Czech Republic](https://en.wikipedia.org/wiki/Czech_Republic). The program reads the Schwab JSON export of your stock transactions, optionally an E-Trade Gain and Loss -XLSX/CSV export, and optionally a Degiro account statement (`.xls`), then creates a summary of your sales, -purchases and dividends for the tax year. +XLSX/CSV export, optionally a Degiro account statement (`.xls`), and optionally Revolut Stocks and +Flexible Cash Funds CSV statements, then creates a summary of your sales, purchases and dividends for +the tax year. All input files referenced in this README (and the YAML config) are assumed to live under the `input/` folder at the repository root (which is git-ignored). See the [Input layout](#input-layout) @@ -99,6 +101,52 @@ Notes: - All currencies present in the file (USD, EUR, CZK, ...) are handled; the daily CNB rate for the value date is used for FX conversion. +# Obtaining Revolut statements + +Revolut does not expose a public API for personal accounts, so the CSV exports from the app +are the only data source. There are two relevant statements: + +## Revolut Stocks (dividends) + +1. In the Revolut app, open **Stocks → ⋯ (More) → Statement**. + +2. Choose **Excel (CSV)** format and the relevant date range (the tax year, with a few days + of overlap is safe). + +3. Save the file into `input/revolut/` (e.g. + `input/revolut/trading-account-statement_2025-01-01_2025-12-31_en-us_.csv`). + +Notes: +- Only `DIVIDEND` rows are processed; `BUY`, `SELL`, `CASH WITHDRAWAL` are ignored. +- **Withholding tax is not reported on the statement.** Revolut deducts US WHT at source and + reports only the *net* amount that landed in your account. The parser therefore performs a + mathematical *gross-up*: `gross = net / (1 - whtRate)` (default `whtRate = 0.15`, the US/CZ + treaty rate when a W-8BEN is on file, which Revolut signs for you automatically). The + computed WHT is emitted as a tax credit so your CZ tax return matches what was actually + withheld. If your treaty rate differs, override `revolut.whtRate` in the YAML. +- `DIVIDEND TAX (CORRECTION)` rows always come in cancelling pairs (a debit and an immediate + credit of the same magnitude); they are summed and ignored, with a log line confirming the + net is zero. + +## Revolut Flexible Cash Funds (interest) + +1. In the Revolut app, open **Savings → Flexible Cash Funds → Statement**. + +2. Choose **Excel (CSV)** format and the relevant date range. Export *one statement per + currency* (e.g. one for the USD fund and one for the EUR fund). + +3. Save the files into `input/revolut/` (e.g. + `input/revolut/savings-statement_2025-01-01_2025-12-31_en-us_.csv`). + +Notes: +- Only `Interest PAID` rows are taken as gross §8 *interest on non-equity securities* + income. `Interest Reinvested`, `BUY`, and `SELL` rows are ignored (no §10 capital-gain + calculation is performed). +- `Service Fee Charged` rows are logged for transparency only — per Revolut's CZ tax + guidance these fees are *not* deductible from the §8 interest base. +- Each statement is a single-currency file; the currency is auto-detected from the + `Value, ` column header. + # Input layout All inputs (broker exports and the YAML config) live under `input/`. A typical layout is: @@ -108,11 +156,15 @@ input/ ├── config.yaml ├── schwab-export.json # Schwab JSON export ├── Accounts_Degiro.xls # Degiro account statement -└── etrade/ # E-Trade data directory - ├── rsu/ *.pdf # RSU release confirmations - ├── espp/ *.pdf # ESPP purchase confirmations - ├── dividends/ *.xlsx # single dividends export - └── sales/ *.xlsx # single Gain & Loss export +├── BenefitHistory.xlsx # E-Trade Benefit History export (RSU + ESPP, optional alternative to etrade/rsu + etrade/espp) +├── etrade/ # E-Trade data directory +│ ├── rsu/ *.pdf # RSU release confirmations (skipped when etradeBenefitHistory is configured) +│ ├── espp/ *.pdf # ESPP purchase confirmations (skipped when etradeBenefitHistory is configured) +│ ├── dividends/ *.xlsx # single dividends export +│ └── sales/ *.xlsx # single Gain & Loss export +└── revolut/ # Revolut CSV statements + ├── trading-account-statement_*.csv # Stocks (dividends) + └── savings-statement_*.csv # Flexible Cash Funds (one per currency) ``` Each broker is optional; include only what applies to you. @@ -132,14 +184,21 @@ schwab: ./input/schwab-export.json # optional etrade: ./input/etrade # optional, layout shown above etradeBenefitHistory: ./input/BenefitHistory.xlsx # optional; alternative to etrade/rsu + etrade/espp degiro: # optional, list of Degiro .xls files - - ./input/Accounts_Degiro_2024.xls + - ./input/Accounts_Degiro_2025.xls +revolut: # optional Revolut block + whtRate: 0.15 # US/CZ treaty rate; override only if yours differs + stocks: # list of Revolut Stocks CSV statements + - ./input/revolut/trading-account-statement_2024-01-01_2024-12-31_en-us_xxxxxx.csv + savings: # list of Flexible Cash Funds CSV statements (one per currency) + - ./input/revolut/savings-statement_2024-01-01_2024-12-31_en-us_USD_xxxxxx.csv + - ./input/revolut/savings-statement_2024-01-01_2024-12-31_en-us_EUR_xxxxxx.csv ``` ``` java -jar target/cockroach-0.3-SNAPSHOT.jar input/config.yaml ``` -At least one of `schwab`, `etrade`, `degiro` must be present. +At least one of `schwab`, `etrade`, `degiro`, `revolut.stocks`, `revolut.savings` must be present. ## Positional arguments (legacy, Schwab + E-Trade only) @@ -171,7 +230,7 @@ With E-Trade data: java -jar target/cockroach-0.3-SNAPSHOT.jar input/schwab-export.json 2025 ./output input/etrade -With YAML config (Schwab and/or E-Trade and/or Degiro): +With YAML config (Schwab and/or E-Trade and/or Degiro and/or Revolut): java -jar target/cockroach-0.3-SNAPSHOT.jar input/config.yaml diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt index e6d7f92..71eeb69 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt @@ -2,6 +2,7 @@ package cz.solutions.cockroach import com.charleskorn.kaml.Yaml import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer import java.io.File @Serializable @@ -18,3 +19,10 @@ data class CockroachConfig( } } } + +@Serializable +data class RevolutConfig( + val whtRate: Double = RevolutParser.DEFAULT_WHT_RATE, + val stocks: List = emptyList(), + val savings: List = emptyList() +) diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index 83d96fd..eb36e53 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -13,6 +13,10 @@ fun main(args: Array) { outputDir = File(config.outputDir), eTradeDir = config.etrade?.let { File(it) }, degiroFiles = config.degiro.map { File(it) } + degiroFiles = config.degiro.map { File(it) }, + revolutStocksFiles = config.revolut.stocks.map { File(it) }, + revolutSavingsFiles = config.revolut.savings.map { File(it) }, + revolutWhtRate = config.revolut.whtRate, ) return } @@ -28,7 +32,7 @@ fun main(args: Array) { System.err.println(" espp/ - ESPP purchase confirmation PDFs") System.err.println(" dividends/ - single dividends XLSX file") System.err.println(" sales/ - single Gain & Loss CSV file") - System.err.println(" config.yaml YAML config file with year, outputDir, schwab, etrade, degiro") + System.err.println(" config.yaml YAML config file with year, outputDir, schwab, etrade, etradeBenefitHistory, degiro") System.exit(1) } val eTradeDir = if (args.size > 3) File(args[3]) else null @@ -48,13 +52,17 @@ object CockroachMain { val schwabExport = schwabExportFile?.let { parseExportFile(it) } ?: ParsedExport.empty() val eTradeExport = eTradeDir?.let { parseETradeDir(it) } ?: ParsedExport.empty() val degiroExport = degiroFiles.map { parseDegiroFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } - val parsedExport = schwabExport + eTradeExport + degiroExport + val revolutStocksExport = revolutStocksFiles.map { parseRevolutStocksFile(it, revolutWhtRate) } + .fold(ParsedExport.empty()) { acc, e -> acc + e } + val revolutSavingsExport = revolutSavingsFiles.map { parseRevolutSavingsFile(it) } + .fold(ParsedExport.empty()) { acc, e -> acc + e } + val parsedExport = schwabExport + eTradeExport + degiroExport + revolutStocksExport + revolutSavingsExport require(parsedExport != ParsedExport.empty()) { - "No input sources provided. Specify at least one of: schwab, etrade, degiro." + "No input sources provided. Specify at least one of: schwab, etrade, degiro, revolut." } val dailyRateProvider = TabularExchangeRateProvider.fromSource( HttpCnbYearRatesSource(HttpCnbYearRatesSource.defaultCacheDir()), - year..year + (year - 1)..year ) val fixedRateReport = ReportGenerator.generateForYear(parsedExport, year, YearConstantExchangeRateProvider.hardcoded()) val dynamicRateReport = ReportGenerator.generateForYear(parsedExport, year, dailyRateProvider) @@ -72,6 +80,7 @@ object CockroachMain { private fun reportOneVariant(year: Int, outputDir: File, data: Report, dollarConversionSchema: String) { File(outputDir, "${dollarConversionSchema}_dividend_$year.pdf").writeBytes(data.getDividendPdf()) + File(outputDir, "${dollarConversionSchema}_interest_$year.pdf").writeBytes(data.getInterestPdf()) File(outputDir, "${dollarConversionSchema}_rsu_$year.pdf").writeBytes(data.getRsuPdf()) File(outputDir, "${dollarConversionSchema}_espp_$year.pdf").writeBytes(data.getEsppPdf()) File(outputDir, "${dollarConversionSchema}_sales_$year.pdf").writeBytes(data.getSalesPdf()) @@ -104,13 +113,45 @@ object CockroachMain { ) } - private fun parseETradeDir(eTradeDir: File): ParsedExport { - val rsuRecords = RsuPdfParser.parseDirectory(File(eTradeDir, "rsu")) - val esppRecords = EsppPdfParser.parseDirectory(File(eTradeDir, "espp")) - val dividentXlsFile = locateSingleFile(File(eTradeDir, "dividends"), "xlsx") + private fun parseRevolutStocksFile(file: File, whtRate: Double): ParsedExport { + val result = RevolutParser.parseStocks(file, whtRate) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = result.dividendRecords, + taxRecords = result.taxRecords, + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList() + ) + } + + private fun parseRevolutSavingsFile(file: File): ParsedExport { + val result = RevolutParser.parseSavings(file) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = emptyList(), + taxRecords = emptyList(), + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList(), + interestRecords = result.interestRecords + ) + } + + private fun parseETradeDir(eTradeDir: File?, benefitHistoryFile: File? = null): ParsedExport { + val benefitHistory = benefitHistoryFile?.let { ETradeBenefitHistoryParser.parse(it) } + val rsuRecords = benefitHistory?.rsuRecords + ?: eTradeDir?.let { RsuPdfParser.parseDirectory(File(it, "rsu")) } + ?: emptyList() + val esppRecords = benefitHistory?.esppRecords + ?: eTradeDir?.let { EsppPdfParser.parseDirectory(File(it, "espp")) } + ?: emptyList() + val dividentXlsFile = eTradeDir?.let { locateSingleFile(File(it, "dividends"), "xlsx") } val dividendXlsxResult = dividentXlsFile?.let { DividendXlsxParser.parse(it)} - val eTradeXlsFile = locateSingleFile(File(eTradeDir, "sales"), "xlsx") - val eTradeCsvFile = locateSingleFile(File(eTradeDir, "sales"), "csv") + val eTradeXlsFile = eTradeDir?.let { locateSingleFile(File(it, "sales"), "xlsx") } + val eTradeCsvFile = eTradeDir?.let { locateSingleFile(File(it, "sales"), "csv") } return ParsedExport( rsuRecords = rsuRecords, diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt new file mode 100644 index 0000000..806c88b --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt @@ -0,0 +1,9 @@ +package cz.solutions.cockroach + +import org.joda.time.LocalDate + +data class InterestRecord( + val date: LocalDate, + val amount: Double, + val currency: Currency = Currency.USD +) diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt new file mode 100644 index 0000000..ab12c37 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt @@ -0,0 +1,34 @@ +package cz.solutions.cockroach + +data class PrintableInterest( + val date: String, + val brutto: String, + val exchange: String, + val bruttoCrown: String +) + +data class PrintableCzkInterest( + val date: String, + val brutto: String +) + +data class CurrencyInterestSection( + val currency: Currency, + val printableInterestList: List, + val totalBrutto: Double, + val totalBruttoCrown: Double +) + +data class CzkInterestSection( + val printableInterestList: List, + val totalBruttoCrown: Double +) + +data class InterestReport( + val sections: List, + val czkSection: CzkInterestSection? +) { + val totalNonCzkBruttoCrown: Double get() = sections.sumOf { it.totalBruttoCrown } + val totalCzkBruttoCrown: Double get() = czkSection?.totalBruttoCrown ?: 0.0 + val totalBruttoCrown: Double get() = totalNonCzkBruttoCrown + totalCzkBruttoCrown +} diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt new file mode 100644 index 0000000..7665422 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt @@ -0,0 +1,80 @@ +package cz.solutions.cockroach + +import org.apache.pdfbox.io.IOUtils +import org.apache.pdfbox.io.RandomAccessReadBuffer +import org.apache.pdfbox.multipdf.PDFMergerUtility +import java.io.ByteArrayOutputStream + +object InterestReportPdfGenerator { + + fun generate(report: InterestReport): ByteArray { + val pdfs = mutableListOf() + for (section in report.sections) { + pdfs.add(generateCurrencySectionPdf(section)) + } + report.czkSection?.let { pdfs.add(generateCzkSectionPdf(it)) } + + if (pdfs.isEmpty()) { + return PdfReportGenerator.generate(PdfReportDefinition( + title = "Úroky (§8) – rozpis", + subtitles = listOf("Žádné úrokové příjmy v daném období."), + columns = listOf(PdfColumn("Datum", 1f)), + rows = emptyList(), + landscape = false + )) + } + if (pdfs.size == 1) return pdfs[0] + return mergePdfs(pdfs) + } + + private fun generateCurrencySectionPdf(section: CurrencyInterestSection): ByteArray { + val cur = section.currency.name + val columns = listOf( + PdfColumn("Datum", 1f), PdfColumn("Brutto ($cur)", 1f), + PdfColumn("Kurz (Kč/$cur)", 1f), PdfColumn("Brutto (Kč)", 1f) + ) + val rows = section.printableInterestList.map { i -> + listOf(i.date, i.brutto, i.exchange, i.bruttoCrown) + } + val fmt = FormatingHelper::formatDouble + val summaryRow = listOf( + SummaryCell.bold("Celkem"), + SummaryCell.bold(fmt(section.totalBrutto)), + SummaryCell.empty(), + SummaryCell.bold(fmt(section.totalBruttoCrown)) + ) + return PdfReportGenerator.generate(PdfReportDefinition( + title = "Úroky (§8) – rozpis – $cur", + subtitles = listOf("Měna zdroje: $cur"), + columns = columns, rows = rows, summaryRow = summaryRow, + landscape = false + )) + } + + private fun generateCzkSectionPdf(section: CzkInterestSection): ByteArray { + val columns = listOf( + PdfColumn("Datum", 1f), PdfColumn("Brutto (Kč)", 1f) + ) + val rows = section.printableInterestList.map { i -> listOf(i.date, i.brutto) } + val fmt = FormatingHelper::formatDouble + val summaryRow = listOf( + SummaryCell.bold("Celkem"), + SummaryCell.bold(fmt(section.totalBruttoCrown)) + ) + return PdfReportGenerator.generate(PdfReportDefinition( + title = "Úroky ze zdrojů v ČR – rozpis", + subtitles = listOf("Měna zdroje: CZK"), + columns = columns, rows = rows, summaryRow = summaryRow, + landscape = false + )) + } + + private fun mergePdfs(pdfs: List): ByteArray { + val merger = PDFMergerUtility() + val out = ByteArrayOutputStream() + merger.destinationStream = out + for (pdf in pdfs) merger.addSource(RandomAccessReadBuffer(pdf)) + merger.mergeDocuments(IOUtils.createMemoryOnlyStreamCache()) + return out.toByteArray() + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt new file mode 100644 index 0000000..3cf3fd0 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt @@ -0,0 +1,75 @@ +package cz.solutions.cockroach + +import org.joda.time.format.DateTimeFormat +import org.joda.time.format.DateTimeFormatter + +object InterestReportPreparation { + private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormat.forPattern("dd.MM.YYYY").withZoneUTC() + + fun generateInterestReport( + interestRecordList: List, + interval: DateInterval, + exchangeRateProvider: ExchangeRateProvider + ): InterestReport { + + val interestsInInterval = interestRecordList.filter { interval.contains(it.date) } + val interestsByCurrency = interestsInInterval.groupBy { it.currency } + + val nonCzkCurrencies = interestsByCurrency.keys + .filter { it != Currency.CZK } + .sortedBy { it.name } + + val sections = nonCzkCurrencies.map { currency -> + buildCurrencySection(currency, interestsByCurrency.getValue(currency), exchangeRateProvider) + } + + val czkSection = interestsByCurrency[Currency.CZK]?.let { buildCzkSection(it) } + + return InterestReport(sections, czkSection) + } + + private fun buildCurrencySection( + currency: Currency, + interestRecords: List, + exchangeRateProvider: ExchangeRateProvider + ): CurrencyInterestSection { + val sortedInterests = interestRecords.sortedBy { it.date } + val printable = mutableListOf() + var totalBrutto = 0.0 + var totalBruttoCrown = 0.0 + + for (interestRecord in sortedInterests) { + val exchange = exchangeRateProvider.rateAt(interestRecord.date, currency) + totalBrutto += interestRecord.amount + totalBruttoCrown += interestRecord.amount * exchange + + printable.add( + PrintableInterest( + DATE_FORMATTER.print(interestRecord.date), + FormatingHelper.formatDouble(interestRecord.amount), + FormatingHelper.formatExchangeRate(exchange), + FormatingHelper.formatDouble(exchange * interestRecord.amount) + ) + ) + } + + return CurrencyInterestSection(currency, printable, totalBrutto, totalBruttoCrown) + } + + private fun buildCzkSection(interestRecords: List): CzkInterestSection { + val sortedInterests = interestRecords.sortedBy { it.date } + val printable = mutableListOf() + var totalBruttoCrown = 0.0 + + for (interestRecord in sortedInterests) { + totalBruttoCrown += interestRecord.amount + printable.add( + PrintableCzkInterest( + DATE_FORMATTER.print(interestRecord.date), + FormatingHelper.formatDouble(interestRecord.amount) + ) + ) + } + return CzkInterestSection(printable, totalBruttoCrown) + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt index 440436a..c2efe84 100644 --- a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt @@ -3,6 +3,7 @@ package cz.solutions.cockroach import kotlinx.serialization.* +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor @@ -33,7 +34,7 @@ class JsonExportParser { return ParsedExport( - export.transactions.filterIsInstance(Transaction.RsuDepositTransaction::class.java).map { + export.transactions.filterIsInstance().map { check(it.transactionDetails.size==1) RsuRecord( it.date, @@ -43,7 +44,7 @@ class JsonExportParser { it.transactionDetails[0].details.awardId ) }, - export.transactions.filterIsInstance(Transaction.EsppDepositTransaction::class.java).map { + export.transactions.filterIsInstance().map { check(it.transactionDetails.size==1) EsppRecord( it.date, @@ -54,26 +55,26 @@ class JsonExportParser { it.transactionDetails[0].details.purchaseDate ) }, - export.transactions.filterIsInstance(Transaction.DividendTransaction::class.java).map { + export.transactions.filterIsInstance().map { DividendRecord( it.date, it.amount ) }, - export.transactions.filterIsInstance(Transaction.TaxWithholdingTransaction::class.java).map { + export.transactions.filterIsInstance().map { TaxRecord( it.date, it.amount ) }, - export.transactions.filterIsInstance(Transaction.TaxReversalTransaction::class.java).map { + export.transactions.filterIsInstance().map { TaxReversalRecord( it.date, it.amount ) }, - export.transactions.filterIsInstance(Transaction.SaleTransaction::class.java).flatMap { + export.transactions.filterIsInstance().flatMap { it.transactionDetails.map {transactionDetail-> SaleRecord( it.date, @@ -88,7 +89,7 @@ class JsonExportParser { } }, - export.transactions.filterIsInstance(Transaction.JournalTransaction::class.java).map { + export.transactions.filterIsInstance().map { JournalRecord( it.date, it.amount?:0.0, diff --git a/src/main/kotlin/cz/solutions/cockroach/ParsedExport.kt b/src/main/kotlin/cz/solutions/cockroach/ParsedExport.kt index ea5ed7f..6daa10d 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ParsedExport.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ParsedExport.kt @@ -7,7 +7,8 @@ data class ParsedExport( val taxRecords: List, val taxReversalRecords: List, val saleRecords: List, - val journalRecords: List + val journalRecords: List, + val interestRecords: List = emptyList() ) { operator fun plus(other: ParsedExport): ParsedExport { return ParsedExport( @@ -17,14 +18,16 @@ data class ParsedExport( taxRecords = taxRecords + other.taxRecords, taxReversalRecords = taxReversalRecords + other.taxReversalRecords, saleRecords = saleRecords + other.saleRecords, - journalRecords = journalRecords + other.journalRecords + journalRecords = journalRecords + other.journalRecords, + interestRecords = interestRecords + other.interestRecords ) } companion object { fun empty(): ParsedExport = ParsedExport( emptyList(), emptyList(), emptyList(), - emptyList(), emptyList(), emptyList(), emptyList() + emptyList(), emptyList(), emptyList(), emptyList(), + emptyList() ) } } \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/Report.kt b/src/main/kotlin/cz/solutions/cockroach/Report.kt index 174fab8..eb5dec0 100644 --- a/src/main/kotlin/cz/solutions/cockroach/Report.kt +++ b/src/main/kotlin/cz/solutions/cockroach/Report.kt @@ -14,6 +14,7 @@ class Report( private val salesReport: SalesReport, private val esppReport2024: EsppReport, private val rsuReport2024: RsuReport, + private val interestReport: InterestReport, ) { private val guideTemplate = TemplateEngine(ReportGenerator::class.java, TemplateHelpers::class.java).load("guide.html.hbs") @@ -24,6 +25,8 @@ class Report( fun getDividendPdf(): ByteArray = DividendReportPdfGenerator.generate(dividendReport) + fun getInterestPdf(): ByteArray = InterestReportPdfGenerator.generate(interestReport) + fun getEsppPdf(): ByteArray = EsppReportPdfGenerator.generate(esppReport) fun getEspp2024Pdf(): ByteArray = EsppReportPdfGenerator.generate(esppReport2024, taxableMode = true, broker = "Charles Schwab & Co.") @@ -52,8 +55,11 @@ class Report( "dividendCroneValue" to FormatingHelper.formatRounded(dividendReport.totalNonCzkBruttoCrown), "dividendPayedTaxCroneValue" to FormatingHelper.formatRounded(-dividendReport.totalNonCzkTaxCrown) ) + val interestVars = mapOf( + "interestCroneValue" to FormatingHelper.formatRounded(interestReport.totalBruttoCrown) + ) - val variables = rsuAndEsppVars + salesVars + dividentVars + val variables = rsuAndEsppVars + salesVars + dividentVars + interestVars return render(guideTemplate, variables) } diff --git a/src/main/kotlin/cz/solutions/cockroach/ReportGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/ReportGenerator.kt index 11816e0..1f30470 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ReportGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ReportGenerator.kt @@ -19,6 +19,11 @@ object ReportGenerator { interval, exchangeRateProvider ), + interestReport = InterestReportPreparation.generateInterestReport( + parsedExport.interestRecords, + interval, + exchangeRateProvider + ), esppReport = EsppReportPreparation.generateEsppReport( parsedExport.esppRecords, parsedExport.saleRecords, diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt new file mode 100644 index 0000000..d5bb3ae --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -0,0 +1,148 @@ +package cz.solutions.cockroach + +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVParser +import org.apache.commons.csv.CSVRecord +import org.joda.time.LocalDate +import org.joda.time.format.DateTimeFormat +import java.io.File +import java.io.Reader +import java.nio.charset.StandardCharsets +import java.util.Locale +import java.util.logging.Logger + +data class RevolutStocksParseResult( + val dividendRecords: List, + val taxRecords: List +) + +data class RevolutSavingsParseResult( + val interestRecords: List +) + +object RevolutParser { + + private val LOGGER = Logger.getLogger(RevolutParser::class.java.name) + + const val DEFAULT_WHT_RATE = 0.15 + + private const val STOCKS_TYPE_DIVIDEND = "DIVIDEND" + private const val STOCKS_TYPE_DIVIDEND_TAX_CORRECTION = "DIVIDEND TAX (CORRECTION)" + + private val SAVINGS_DATE_FORMATTER = DateTimeFormat.forPattern("MMM d, yyyy, h:mm:ss a").withLocale(Locale.US) + + fun parseStocks(file: File, whtRate: Double = DEFAULT_WHT_RATE): RevolutStocksParseResult { + return file.reader(StandardCharsets.UTF_8).use { parseStocks(it, whtRate) } + } + + fun parseStocks(reader: Reader, whtRate: Double = DEFAULT_WHT_RATE): RevolutStocksParseResult { + require(whtRate in 0.0..0.99) { "whtRate must be in [0, 0.99), was $whtRate" } + val format = CSVFormat.Builder.create(CSVFormat.DEFAULT) + .setHeader().setSkipHeaderRecord(true).build() + val dividends = mutableListOf() + val taxes = mutableListOf() + var correctionPairTotal = 0.0 + var correctionRowCount = 0 + + CSVParser.parse(reader, format).use { parser -> + for (record in parser) { + val type = record.get("Type").trim() + when (type) { + STOCKS_TYPE_DIVIDEND -> { + val date = parseStocksDate(record.get("Date")) + val (amount, currency) = parseAmountAndCurrency(record) + if (amount <= 0.0) { + LOGGER.warning("Revolut Stocks: skipping non-positive DIVIDEND row at $date (${record.get("Total Amount")})") + continue + } + val gross = amount / (1.0 - whtRate) + val wht = gross - amount + dividends.add(DividendRecord(date, gross, currency)) + if (wht > 0.0) { + taxes.add(TaxRecord(date, -wht, currency)) + } + } + STOCKS_TYPE_DIVIDEND_TAX_CORRECTION -> { + val (amount, _) = parseAmountAndCurrency(record) + correctionPairTotal += amount + correctionRowCount++ + } + else -> { /* CASH WITHDRAWAL, BUY, SELL, ... ignored */ } + } + } + } + + if (correctionRowCount > 0) { + if (Math.abs(correctionPairTotal) < 0.01) { + LOGGER.info("Revolut Stocks: $correctionRowCount DIVIDEND TAX (CORRECTION) row(s) summed to ${"%.2f".format(correctionPairTotal)} (cancelling pairs); ignored.") + } else { + LOGGER.warning("Revolut Stocks: $correctionRowCount DIVIDEND TAX (CORRECTION) row(s) summed to ${"%.2f".format(correctionPairTotal)} (non-zero net); ignored - inspect statement manually.") + } + } + LOGGER.info("Revolut Stocks: parsed ${dividends.size} dividend(s); grossed-up at WHT rate ${"%.4f".format(whtRate)} producing ${taxes.size} tax record(s).") + return RevolutStocksParseResult(dividends, taxes) + } + + fun parseSavings(file: File): RevolutSavingsParseResult { + return file.reader(StandardCharsets.UTF_8).use { parseSavings(it) } + } + + fun parseSavings(reader: Reader): RevolutSavingsParseResult { + val format = CSVFormat.Builder.create(CSVFormat.DEFAULT) + .setHeader().setSkipHeaderRecord(true).build() + val interestRecords = mutableListOf() + var feeTotal = 0.0 + var feeCount = 0 + var feeCurrency: Currency? = null + + CSVParser.parse(reader, format).use { parser -> + val valueColumn = parser.headerNames.firstOrNull { it.startsWith("Value, ") && it != "Value, CZK" } + ?: throw IllegalArgumentException("Revolut Savings: no 'Value, ' column found in header ${parser.headerNames}") + val currency = Currency.valueOf(valueColumn.removePrefix("Value, ").trim()) + + for (record in parser) { + val description = record.get("Description").trim() + val rawValue = record.get(valueColumn).trim() + if (rawValue.isEmpty()) continue + val value = rawValue.replace(",", "").toDoubleOrNull() ?: continue + val date = parseSavingsDate(record.get("Date")) + when { + description.startsWith("Interest PAID") -> { + if (value > 0.0) interestRecords.add(InterestRecord(date, value, currency)) + } + description.startsWith("Service Fee Charged") -> { + feeTotal += value + feeCount++ + feeCurrency = currency + } + else -> { /* Interest Reinvested, BUY, SELL: ignored */ } + } + } + } + if (feeCount > 0) { + LOGGER.info("Revolut Savings: ignored $feeCount Service Fee Charged row(s) totalling ${"%.4f".format(feeTotal)} ${feeCurrency?.name} (informational only; not deductible from §8 interest base per Revolut CZ tax guidance).") + } + LOGGER.info("Revolut Savings: parsed ${interestRecords.size} Interest PAID row(s) as gross §8 interest income.") + return RevolutSavingsParseResult(interestRecords) + } + + private fun parseStocksDate(value: String): LocalDate { + return LocalDate.parse(value.trim().substring(0, 10)) + } + + private fun parseSavingsDate(value: String): LocalDate { + // "Dec 31, 2025, 1:51:12 AM" — Revolut uses U+202F (narrow no-break space) and/or + // U+00A0 (non-breaking space) around the AM/PM marker; normalise to ASCII space first. + val normalised = value.trim().replace('\u202F', ' ').replace('\u00A0', ' ') + return SAVINGS_DATE_FORMATTER.parseLocalDate(normalised) + } + + private fun parseAmountAndCurrency(record: CSVRecord): Pair { + val raw = record.get("Total Amount").trim() + val currencyStr = record.get("Currency").trim() + val currency = Currency.valueOf(currencyStr) + val numericPart = raw.removePrefix(currencyStr).trim().replace(",", "") + val amount = numericPart.toDouble() + return amount to currency + } +} diff --git a/src/main/resources/cz/solutions/cockroach/guide.html.hbs b/src/main/resources/cz/solutions/cockroach/guide.html.hbs index c95c4d0..3018ca7 100644 --- a/src/main/resources/cz/solutions/cockroach/guide.html.hbs +++ b/src/main/resources/cz/solutions/cockroach/guide.html.hbs @@ -243,7 +243,18 @@ 414 Daň ze samotného základu daně podle § 16a zákona...0 +

+ +

Interest

+

Úrokové příjmy ze zahraničí (např. Revolut Flexible Cash Funds) bez sražené zahraniční daně. Patří do § 8 a uvádějí se na Příloze č. 2 DAP, kde se přičítají k dílčímu základu daně z kapitálového majetku.

+ + + + + + +
PopisVyplní v celých Kč
Úhrn úrokových příjmů (§ 8) ze zahraničí v Kč{{interestCroneValue}}
\ No newline at end of file diff --git a/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt new file mode 100644 index 0000000..7046dd4 --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt @@ -0,0 +1,91 @@ +package cz.solutions.cockroach + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset.offset +import org.joda.time.LocalDate +import org.junit.jupiter.api.Test + +class InterestReportPreparationTest { + + private val fixedRate = ExchangeRateProvider { _, currency -> + when (currency) { + Currency.USD -> 20.0 + Currency.EUR -> 25.0 + else -> 1.0 + } + } + + @Test + fun aggregatesInterestPerCurrencyAndAppliesExchangeRate() { + val records = listOf( + InterestRecord(LocalDate(2025, 3, 15), 10.0, Currency.USD), + InterestRecord(LocalDate(2025, 6, 20), 5.0, Currency.USD), + InterestRecord(LocalDate(2025, 9, 10), 4.0, Currency.EUR), + ) + + val report = InterestReportPreparation.generateInterestReport( + records, DateInterval.year(2025), fixedRate + ) + + assertThat(report.sections).hasSize(2) + assertThat(report.czkSection).isNull() + + val usd = report.sections.first { it.currency == Currency.USD } + assertThat(usd.totalBrutto).isCloseTo(15.0, offset(0.0001)) + assertThat(usd.totalBruttoCrown).isCloseTo(300.0, offset(0.0001)) + assertThat(usd.printableInterestList).hasSize(2) + + val eur = report.sections.first { it.currency == Currency.EUR } + assertThat(eur.totalBrutto).isCloseTo(4.0, offset(0.0001)) + assertThat(eur.totalBruttoCrown).isCloseTo(100.0, offset(0.0001)) + + assertThat(report.totalNonCzkBruttoCrown).isCloseTo(400.0, offset(0.0001)) + assertThat(report.totalBruttoCrown).isCloseTo(400.0, offset(0.0001)) + } + + @Test + fun filtersRecordsOutsideOfInterval() { + val records = listOf( + InterestRecord(LocalDate(2024, 12, 31), 100.0, Currency.USD), + InterestRecord(LocalDate(2025, 1, 1), 1.0, Currency.USD), + InterestRecord(LocalDate(2025, 12, 31), 2.0, Currency.USD), + InterestRecord(LocalDate(2026, 1, 1), 100.0, Currency.USD), + ) + + val report = InterestReportPreparation.generateInterestReport( + records, DateInterval.year(2025), fixedRate + ) + + assertThat(report.sections).hasSize(1) + assertThat(report.sections[0].totalBrutto).isCloseTo(3.0, offset(0.0001)) + assertThat(report.sections[0].totalBruttoCrown).isCloseTo(60.0, offset(0.0001)) + } + + @Test + fun keepsCzkInterestInDedicatedSection() { + val records = listOf( + InterestRecord(LocalDate(2025, 5, 5), 1234.50, Currency.CZK), + InterestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD), + ) + + val report = InterestReportPreparation.generateInterestReport( + records, DateInterval.year(2025), fixedRate + ) + + assertThat(report.sections).hasSize(1) + assertThat(report.sections[0].currency).isEqualTo(Currency.USD) + assertThat(report.czkSection).isNotNull + assertThat(report.czkSection!!.totalBruttoCrown).isCloseTo(1234.50, offset(0.0001)) + assertThat(report.totalBruttoCrown).isCloseTo(1234.50 + 2000.0, offset(0.0001)) + } + + @Test + fun emptyInputProducesEmptyReport() { + val report = InterestReportPreparation.generateInterestReport( + emptyList(), DateInterval.year(2025), fixedRate + ) + assertThat(report.sections).isEmpty() + assertThat(report.czkSection).isNull() + assertThat(report.totalBruttoCrown).isEqualTo(0.0) + } +} diff --git a/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt new file mode 100644 index 0000000..ddf5e62 --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt @@ -0,0 +1,109 @@ +package cz.solutions.cockroach + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset.offset +import org.joda.time.LocalDate +import org.junit.jupiter.api.Test +import java.io.StringReader + +class RevolutParserTest { + + @Test + fun parsesStocksDividendsWithGrossUpAtDefaultWhtRate() { + val csv = """ + Date,Ticker,Type,Quantity,Price per share,Total Amount,Currency,FX Rate + 2025-01-09T09:16:47.755556Z,MRK,DIVIDEND,,,USD 8.50,USD,0.0412 + 2025-02-04T13:30:09.298662Z,T,DIVIDEND,,,USD 3.47,USD,0.0412 + """.trimIndent() + + val result = RevolutParser.parseStocks(StringReader(csv)) + + assertThat(result.dividendRecords).hasSize(2) + assertThat(result.dividendRecords[0].date).isEqualTo(LocalDate(2025, 1, 9)) + assertThat(result.dividendRecords[0].amount).isCloseTo(10.0, offset(0.0001)) + assertThat(result.dividendRecords[0].currency).isEqualTo(Currency.USD) + assertThat(result.taxRecords).hasSize(2) + assertThat(result.taxRecords[0].date).isEqualTo(LocalDate(2025, 1, 9)) + assertThat(result.taxRecords[0].amount).isCloseTo(-1.50, offset(0.0001)) + assertThat(result.taxRecords[0].currency).isEqualTo(Currency.USD) + } + + @Test + fun stocksWhtRateZeroProducesNoTaxRecords() { + val csv = """ + Date,Ticker,Type,Quantity,Price per share,Total Amount,Currency,FX Rate + 2025-01-09T09:16:47.755556Z,MRK,DIVIDEND,,,USD 8.50,USD,0.0412 + """.trimIndent() + + val result = RevolutParser.parseStocks(StringReader(csv), whtRate = 0.0) + + assertThat(result.dividendRecords).hasSize(1) + assertThat(result.dividendRecords[0].amount).isCloseTo(8.50, offset(0.0001)) + assertThat(result.taxRecords).isEmpty() + } + + @Test + fun stocksIgnoresCashWithdrawalAndCancellingTaxCorrectionPairs() { + val csv = """ + Date,Ticker,Type,Quantity,Price per share,Total Amount,Currency,FX Rate + 2025-06-27T07:59:16.209477Z,JNJ,DIVIDEND TAX (CORRECTION),,,USD -0.29,USD,0.0475 + 2025-06-27T07:59:16.300231Z,JNJ,DIVIDEND TAX (CORRECTION),,,USD 0.29,USD,0.0475 + 2025-10-02T05:40:01.919310Z,,CASH WITHDRAWAL,,,USD -96.39,USD,0.0485 + 2025-12-30T12:39:09.676817Z,GILD,DIVIDEND,,,USD 4.38,USD,0.0487 + """.trimIndent() + + val result = RevolutParser.parseStocks(StringReader(csv)) + + assertThat(result.dividendRecords).hasSize(1) + assertThat(result.dividendRecords[0].date).isEqualTo(LocalDate(2025, 12, 30)) + assertThat(result.taxRecords).hasSize(1) + } + + @Test + fun parsesSavingsInterestAndIgnoresFeesAndBuySell() { + val csv = """ + Date,Description,"Value, USD","Value, CZK",FX Rate,Price per share,Quantity of shares + "Dec 31, 2025, 1:51:12 AM",Service Fee Charged USD Class IE000H9J0QX4,-1.2993,-26.7562,20.5923,, + "Dec 31, 2025, 1:51:12 AM",Interest PAID USD Class R IE000H9J0QX4,3.4493,71.0291,20.5923,, + "Dec 30, 2025, 1:50:13 AM",Interest PAID USD Class R IE000H9J0QX4,3.4393,71.0042,20.6450,, + "Dec 29, 2025, 1:49:55 AM",BUY USD Class R IE000H9J0QX4,500.0000,10300.0000,20.6000,1.000,500 + "Dec 28, 2025, 1:49:41 AM",SELL USD Class R IE000H9J0QX4,-1130.0000,-23278.0000,20.6000,1.000,-1130 + "Dec 27, 2025, 1:48:53 AM",Interest Reinvested Class R USD IE000H9J0QX4,-3.4193,-70.5000,20.6000,, + """.trimIndent() + + val result = RevolutParser.parseSavings(StringReader(csv)) + + assertThat(result.interestRecords).hasSize(2) + assertThat(result.interestRecords[0].date).isEqualTo(LocalDate(2025, 12, 31)) + assertThat(result.interestRecords[0].amount).isCloseTo(3.4493, offset(0.0001)) + assertThat(result.interestRecords[0].currency).isEqualTo(Currency.USD) + assertThat(result.interestRecords[1].date).isEqualTo(LocalDate(2025, 12, 30)) + } + + @Test + fun parsesSavingsEurStatement() { + val csv = """ + Date,Description,"Value, EUR","Value, CZK",FX Rate,Price per share,Quantity of shares + "Dec 31, 2025, 1:51:12 AM",Interest PAID EUR Class R IE000AZVL3K0,1.2345,30.5000,24.7000,, + """.trimIndent() + + val result = RevolutParser.parseSavings(StringReader(csv)) + + assertThat(result.interestRecords).hasSize(1) + assertThat(result.interestRecords[0].currency).isEqualTo(Currency.EUR) + assertThat(result.interestRecords[0].amount).isCloseTo(1.2345, offset(0.0001)) + } + + @Test + fun parsesSavingsDateWithNarrowNoBreakSpaceBeforeAmPm() { + // Real Revolut CSVs use U+202F (NARROW NO-BREAK SPACE) between time and AM/PM. + val csv = "Date,Description,\"Value, USD\",\"Value, CZK\",FX Rate,Price per share,Quantity of shares\n" + + "\"Dec 31, 2025, 1:21:00\u202FAM\",Interest PAID USD Class R IE000H9J0QX4,2.5000,52.0000,20.8000,,\n" + + val result = RevolutParser.parseSavings(StringReader(csv)) + + assertThat(result.interestRecords).hasSize(1) + assertThat(result.interestRecords[0].date).isEqualTo(LocalDate(2025, 12, 31)) + assertThat(result.interestRecords[0].amount).isCloseTo(2.5, offset(0.0001)) + } +} From 12139057b4dbefba06b328997b269e86276f0115 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 14:42:31 +0200 Subject: [PATCH 03/31] e-trade import from csv instead of pdf files --- .../cz/solutions/cockroach/CockroachConfig.kt | 4 +- .../cz/solutions/cockroach/CockroachMain.kt | 12 +- .../cockroach/ETradeBenefitHistoryParser.kt | 159 ++++++++++++++++++ .../ETradeBenefitHistoryParserTest.kt | 64 +++++++ 4 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt create mode 100644 src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt index 71eeb69..77b94d8 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt @@ -11,7 +11,9 @@ data class CockroachConfig( val outputDir: String, val schwab: String? = null, val etrade: String? = null, - val degiro: List = emptyList() + val etradeBenefitHistory: String? = null, + val degiro: List = emptyList(), + val revolut: RevolutConfig = RevolutConfig() ) { companion object { fun load(file: File): CockroachConfig { diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index eb36e53..2a16bcd 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -12,7 +12,7 @@ fun main(args: Array) { year = config.year, outputDir = File(config.outputDir), eTradeDir = config.etrade?.let { File(it) }, - degiroFiles = config.degiro.map { File(it) } + eTradeBenefitHistoryFile = config.etradeBenefitHistory?.let { File(it) }, degiroFiles = config.degiro.map { File(it) }, revolutStocksFiles = config.revolut.stocks.map { File(it) }, revolutSavingsFiles = config.revolut.savings.map { File(it) }, @@ -47,10 +47,16 @@ object CockroachMain { year: Int, outputDir: File, eTradeDir: File? = null, - degiroFiles: List = emptyList() + eTradeBenefitHistoryFile: File? = null, + degiroFiles: List = emptyList(), + revolutStocksFiles: List = emptyList(), + revolutSavingsFiles: List = emptyList(), + revolutWhtRate: Double = RevolutParser.DEFAULT_WHT_RATE ) { val schwabExport = schwabExportFile?.let { parseExportFile(it) } ?: ParsedExport.empty() - val eTradeExport = eTradeDir?.let { parseETradeDir(it) } ?: ParsedExport.empty() + val eTradeExport = if (eTradeDir != null || eTradeBenefitHistoryFile != null) { + parseETradeDir(eTradeDir, eTradeBenefitHistoryFile) + } else ParsedExport.empty() val degiroExport = degiroFiles.map { parseDegiroFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } val revolutStocksExport = revolutStocksFiles.map { parseRevolutStocksFile(it, revolutWhtRate) } .fold(ParsedExport.empty()) { acc, e -> acc + e } diff --git a/src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt b/src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt new file mode 100644 index 0000000..21c3852 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt @@ -0,0 +1,159 @@ +package cz.solutions.cockroach + +import org.apache.poi.ss.usermodel.Cell +import org.apache.poi.ss.usermodel.CellType +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.Sheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.joda.time.LocalDate +import org.joda.time.format.DateTimeFormat +import java.io.File +import java.io.InputStream +import java.util.Locale + +data class ETradeBenefitHistoryResult( + val rsuRecords: List, + val esppRecords: List +) + +object ETradeBenefitHistoryParser { + + private val PURCHASE_DATE_FORMATTER = DateTimeFormat.forPattern("dd-MMM-yyyy").withLocale(Locale.ENGLISH) + private val VEST_DATE_FORMATTER = DateTimeFormat.forPattern("MM/dd/yyyy") + + private const val COL_RECORD_TYPE = "Record Type" + + private const val COL_PURCHASE_DATE = "Purchase Date" + private const val COL_PURCHASE_PRICE = "Purchase Price" + private const val COL_PURCHASED_QTY = "Purchased Qty." + private const val COL_GRANT_DATE_FMV = "Grant Date FMV" + private const val COL_PURCHASE_DATE_FMV = "Purchase Date FMV" + + private const val COL_GRANT_NUMBER = "Grant Number" + private const val COL_VEST_PERIOD = "Vest Period" + private const val COL_VEST_DATE = "Vest Date" + private const val COL_VESTED_QTY = "Vested Qty." + private const val COL_TAXABLE_GAIN = "Taxable Gain" + + private const val REC_PURCHASE = "Purchase" + private const val REC_VEST_SCHEDULE = "Vest Schedule" + private const val REC_TAX_WITHHOLDING = "Tax Withholding" + + fun parse(file: File): ETradeBenefitHistoryResult = file.inputStream().use { parse(it) } + + fun parse(inputStream: InputStream): ETradeBenefitHistoryResult { + XSSFWorkbook(inputStream).use { workbook -> + var espp = emptyList() + var rsu = emptyList() + for (i in 0 until workbook.numberOfSheets) { + val sheet = workbook.getSheetAt(i) + val header = sheet.getRow(sheet.firstRowNum) ?: continue + val cols = buildColumnIndex(header) + when { + cols.containsKey(COL_PURCHASED_QTY) -> espp = parseEspp(sheet, cols) + cols.containsKey(COL_TAXABLE_GAIN) -> rsu = parseRsu(sheet, cols) + } + } + return ETradeBenefitHistoryResult(rsuRecords = rsu, esppRecords = espp) + } + } + + private fun parseEspp(sheet: Sheet, cols: Map): List { + val out = mutableListOf() + for (i in (sheet.firstRowNum + 1)..sheet.lastRowNum) { + val row = sheet.getRow(i) ?: continue + if (str(row, cols, COL_RECORD_TYPE) != REC_PURCHASE) continue + val date = LocalDate.parse(requireStr(row, cols, COL_PURCHASE_DATE).uppercase(), PURCHASE_DATE_FORMATTER) + out += EsppRecord( + date = date, + quantity = num(row, cols, COL_PURCHASED_QTY), + purchasePrice = num(row, cols, COL_PURCHASE_PRICE), + subscriptionFmv = num(row, cols, COL_GRANT_DATE_FMV), + purchaseFmv = num(row, cols, COL_PURCHASE_DATE_FMV), + purchaseDate = date + ) + } + return out + } + + private fun parseRsu(sheet: Sheet, cols: Map): List { + // Pair Vest Schedule rows (where Vested Qty > 0) with their matching Tax Withholding row + // identified by (Grant Number, Vest Period) appearing immediately below. + data class Pending(val grantId: String, val period: String, val vestDate: LocalDate, val vestedQty: Int) + + val out = mutableListOf() + var pending: Pending? = null + for (i in (sheet.firstRowNum + 1)..sheet.lastRowNum) { + val row = sheet.getRow(i) ?: continue + when (str(row, cols, COL_RECORD_TYPE)) { + REC_VEST_SCHEDULE -> { + pending = null + val vested = optNum(row, cols, COL_VESTED_QTY)?.toInt() ?: 0 + if (vested <= 0) continue + pending = Pending( + grantId = requireStr(row, cols, COL_GRANT_NUMBER), + period = requireStr(row, cols, COL_VEST_PERIOD), + vestDate = LocalDate.parse(requireStr(row, cols, COL_VEST_DATE), VEST_DATE_FORMATTER), + vestedQty = vested + ) + } + REC_TAX_WITHHOLDING -> { + val p = pending ?: continue + if (str(row, cols, COL_GRANT_NUMBER) != p.grantId) { pending = null; continue } + if (str(row, cols, COL_VEST_PERIOD) != p.period) { pending = null; continue } + val taxableGain = num(row, cols, COL_TAXABLE_GAIN) + out += RsuRecord( + date = p.vestDate, + quantity = p.vestedQty, + vestFmv = taxableGain / p.vestedQty, + vestDate = p.vestDate, + grantId = p.grantId + ) + pending = null + } + else -> pending = null + } + } + return out + } + + private fun buildColumnIndex(headerRow: Row): Map { + // The Restricted Stock sheet repeats some header names across row types + // (e.g. "Vested Qty." appears for the Grant total and again for each Vest Schedule). + // The per-row-type columns sit further to the right, so the later occurrence wins. + val index = mutableMapOf() + for (i in 0..headerRow.lastCellNum) { + val cell = headerRow.getCell(i) ?: continue + if (cell.cellType == CellType.STRING) index[cell.stringCellValue.trim()] = i + } + return index + } + + private fun str(row: Row, cols: Map, name: String): String? { + val idx = cols[name] ?: return null + val cell = row.getCell(idx) ?: return null + return cellAsString(cell).takeIf { it.isNotBlank() } + } + + private fun requireStr(row: Row, cols: Map, name: String): String = + str(row, cols, name) ?: throw IllegalArgumentException("Missing '$name' in row ${row.rowNum + 1}") + + private fun num(row: Row, cols: Map, name: String): Double = + optNum(row, cols, name) ?: throw IllegalArgumentException("Missing '$name' in row ${row.rowNum + 1}") + + private fun optNum(row: Row, cols: Map, name: String): Double? { + val raw = str(row, cols, name) ?: return null + return raw.replace("$", "").replace("\u00a0", "").replace(",", "").trim().toDouble() + } + + private fun cellAsString(cell: Cell): String = when (cell.cellType) { + CellType.STRING -> cell.stringCellValue.trim() + CellType.NUMERIC -> { + val d = cell.numericCellValue + if (d == d.toLong().toDouble()) d.toLong().toString() else d.toString() + } + CellType.BOOLEAN -> cell.booleanCellValue.toString() + CellType.FORMULA -> cell.toString().trim() + else -> "" + } +} diff --git a/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt new file mode 100644 index 0000000..91f4c71 --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt @@ -0,0 +1,64 @@ +package cz.solutions.cockroach + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.joda.time.LocalDate +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import java.io.File + +class ETradeBenefitHistoryParserTest { + + companion object { + private val ACTUAL_FILE = File("input/BenefitHistory.xlsx") + private const val EPS = 0.001 + } + + @Test + fun parsesEsppPurchasesFromBenefitHistory() { + assumeTrue(ACTUAL_FILE.exists(), "input/BenefitHistory.xlsx not available, skipping") + + val result = ETradeBenefitHistoryParser.parse(ACTUAL_FILE) + + assertThat(result.esppRecords).hasSize(3) + + val purchase2025Q1 = result.esppRecords.single { it.purchaseDate == LocalDate(2025, 3, 15) } + assertThat(purchase2025Q1.quantity).isEqualTo(177.0, within(EPS)) + assertThat(purchase2025Q1.purchasePrice).isEqualTo(42.143, within(EPS)) + assertThat(purchase2025Q1.subscriptionFmv).isEqualTo(49.58, within(EPS)) + assertThat(purchase2025Q1.purchaseFmv).isEqualTo(50.92, within(EPS)) + + val purchase2025Q3 = result.esppRecords.single { it.purchaseDate == LocalDate(2025, 9, 15) } + assertThat(purchase2025Q3.purchaseFmv).isEqualTo(86.76, within(EPS)) + + val purchase2026Q1 = result.esppRecords.single { it.purchaseDate == LocalDate(2026, 3, 15) } + assertThat(purchase2026Q1.purchaseFmv).isEqualTo(61.50, within(EPS)) + } + + @Test + fun parsesRsuVestsFromBenefitHistory() { + assumeTrue(ACTUAL_FILE.exists(), "input/BenefitHistory.xlsx not available, skipping") + + val result = ETradeBenefitHistoryParser.parse(ACTUAL_FILE) + + // Future grants (e.g. 3-89431 with no vested shares) must be excluded. + assertThat(result.rsuRecords.map { it.grantId }).containsOnly("3-82769", "3-74067") + + val grant82769 = result.rsuRecords.filter { it.grantId == "3-82769" } + assertThat(grant82769).hasSize(4) + assertThat(grant82769.first { it.vestDate == LocalDate(2025, 6, 20) }) + .satisfies({ assertThat(it.quantity).isEqualTo(66) }, + { assertThat(it.vestFmv).isEqualTo(52.870, within(EPS)) }) + assertThat(grant82769.first { it.vestDate == LocalDate(2025, 9, 20) }.vestFmv) + .isEqualTo(87.870, within(EPS)) + + val grant74067 = result.rsuRecords.filter { it.grantId == "3-74067" } + assertThat(grant74067).hasSize(4) + assertThat(grant74067.first { it.vestDate == LocalDate(2025, 6, 20) }) + .satisfies({ assertThat(it.quantity).isEqualTo(829) }, + { assertThat(it.vestFmv).isEqualTo(52.870, within(EPS)) }) + assertThat(grant74067.first { it.vestDate == LocalDate(2026, 3, 20) }) + .satisfies({ assertThat(it.quantity).isEqualTo(208) }, + { assertThat(it.vestFmv).isEqualTo(65.450, within(EPS)) }) + } +} From cca6528998357c2ef4fe62545bdfa9f5190b47a7 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 16:13:27 +0200 Subject: [PATCH 04/31] show broker and symbol on each statement per row for combined statements from multiple employers and brokers --- .../cockroach/DegiroAccountStatementParser.kt | 10 +++-- .../cz/solutions/cockroach/DividendRecord.kt | 4 +- .../cockroach/DividendReportPdfGenerator.kt | 16 +++++--- .../solutions/cockroach/DividendXlsxParser.kt | 8 +++- .../cockroach/DividentReportPreparation.kt | 4 ++ .../cockroach/ETradeBenefitHistoryParser.kt | 19 ++++++++- .../cockroach/ETradeGainLossParser.kt | 5 ++- .../cockroach/ETradeGainLossXlsParser.kt | 6 ++- .../cz/solutions/cockroach/EsppPdfParser.kt | 10 ++++- .../cz/solutions/cockroach/EsppRecord.kt | 4 +- .../cockroach/EsppReportPdfGenerator.kt | 9 +++-- .../cockroach/EsppReportPreparation.kt | 6 +++ .../cz/solutions/cockroach/InterestRecord.kt | 4 +- .../cz/solutions/cockroach/InterestReport.kt | 4 ++ .../cockroach/InterestReportPdfGenerator.kt | 14 +++++-- .../cockroach/InterestReportPreparation.kt | 4 ++ .../solutions/cockroach/JsonExportParser.kt | 24 ++++++++--- .../solutions/cockroach/PdfReportGenerator.kt | 40 ++++++++++++++++--- .../cockroach/PrintableCzkDividend.kt | 2 + .../solutions/cockroach/PrintableDividend.kt | 2 + .../cz/solutions/cockroach/PrintableEspp.kt | 2 + .../cz/solutions/cockroach/PrintableRsu.kt | 2 + .../cz/solutions/cockroach/PrintableSale.kt | 3 ++ .../kotlin/cz/solutions/cockroach/Report.kt | 4 +- .../cz/solutions/cockroach/RevolutParser.kt | 36 +++++++++++------ .../cz/solutions/cockroach/RsuPdfParser.kt | 10 ++++- .../cz/solutions/cockroach/RsuRecord.kt | 4 +- .../cockroach/RsuReportPdfGenerator.kt | 9 +++-- .../cockroach/RsuReportPreparation.kt | 6 +++ .../cz/solutions/cockroach/SaleRecord.kt | 4 +- .../cockroach/SalesReportPdfGenerator.kt | 12 ++++-- .../cockroach/SalesReportPreparation.kt | 2 + .../DegiroAccountStatementParserTest.kt | 8 ++-- .../cockroach/DividendXlsxParserTest.kt | 4 +- .../ETradeBenefitHistoryParserTest.kt | 6 +++ .../cockroach/ETradeGainLossParserTest.kt | 12 ++++-- .../cockroach/ETradeGainLossXlsParserTest.kt | 7 +++- .../solutions/cockroach/EsppPdfParserTest.kt | 12 ++++-- .../cockroach/JsonExportParserTest.kt | 22 +++++++--- .../solutions/cockroach/RsuPdfParserTest.kt | 16 ++++++-- 40 files changed, 294 insertions(+), 82 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt index 419c443..c024d36 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt @@ -29,10 +29,13 @@ object DegiroAccountStatementParser { private const val DESC_ADR_FEE = "ADR/GDR Pass-Through poplatek" private const val COL_VALUE_DATE = 2 + private const val COL_PRODUCT = 3 private const val COL_DESCRIPTION = 5 private const val COL_CURRENCY = 7 private const val COL_AMOUNT = 8 + private const val BROKER_NAME = "Degiro" + fun parse(file: File): DegiroParseResult { return file.inputStream().use { parse(it) } } @@ -57,7 +60,7 @@ object DegiroAccountStatementParser { when (description.trim()) { DESC_DIVIDEND -> { val record = parseRecord(row) ?: continue - dividends.add(DividendRecord(record.date, record.amount, record.currency)) + dividends.add(DividendRecord(record.date, record.amount, record.currency, symbol = record.product, broker = BROKER_NAME)) } DESC_TAX -> { val record = parseRecord(row) ?: continue @@ -79,12 +82,13 @@ object DegiroAccountStatementParser { return DegiroParseResult(dividends, taxes) } - private data class ParsedRow(val date: LocalDate, val amount: Double, val currency: Currency) + private data class ParsedRow(val date: LocalDate, val amount: Double, val currency: Currency, val product: String) private fun parseRecord(row: Row): ParsedRow? { val dateStr = stringCell(row, COL_VALUE_DATE) ?: return null val currencyStr = stringCell(row, COL_CURRENCY) ?: return null val amountStr = stringCell(row, COL_AMOUNT) ?: return null + val product = stringCell(row, COL_PRODUCT)?.trim().orEmpty() val date = LocalDate.parse(dateStr.trim(), DATE_FORMATTER) val currency = try { @@ -94,7 +98,7 @@ object DegiroAccountStatementParser { return null } val amount = parseAmount(amountStr) ?: return null - return ParsedRow(date, amount, currency) + return ParsedRow(date, amount, currency, product) } private fun parseAmount(input: String): Double? { diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt index 9870131..f3e0282 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt @@ -5,5 +5,7 @@ import org.joda.time.LocalDate data class DividendRecord( val date: LocalDate, val amount: Double, - val currency: Currency = Currency.USD + val currency: Currency = Currency.USD, + val symbol: String = "", + val broker: String = "", ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt index 3de98a0..eaa8d7c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt @@ -30,14 +30,17 @@ object DividendReportPdfGenerator { private fun generateCurrencySectionPdf(section: CurrencyDividendSection): ByteArray { val cur = section.currency.name val columns = listOf( - PdfColumn("Datum", 1f), PdfColumn("Brutto ($cur)", 1f), PdfColumn("Sražená daň ($cur)", 1f), - PdfColumn("Kurz (Kč/$cur)", 1f), PdfColumn("Brutto (Kč)", 1f), PdfColumn("Sražená daň (Kč)", 1f) + PdfColumn("Cenný papír", 1.8f), PdfColumn("Obchodník", 1.5f), + PdfColumn("Datum", 0.9f), PdfColumn("Brutto ($cur)", 0.9f), PdfColumn("Sražená daň ($cur)", 1.1f), + PdfColumn("Kurz (Kč/$cur)", 1f), PdfColumn("Brutto (Kč)", 0.9f), PdfColumn("Sražená daň (Kč)", 1.1f) ) val rows = section.printableDividendList.map { d -> - listOf(d.date, d.brutto, d.tax, d.exchange, d.bruttoCrown, d.taxCrown) + listOf(d.symbol, d.broker, d.date, d.brutto, d.tax, d.exchange, d.bruttoCrown, d.taxCrown) } val fmt = FormatingHelper::formatDouble val summaryRow = listOf( + SummaryCell.empty(), // Cenný papír + SummaryCell.empty(), // Obchodník SummaryCell.bold("Celkem"), SummaryCell.bold(fmt(section.totalBrutto)), SummaryCell.bold(fmt(section.totalTax)), @@ -59,11 +62,14 @@ object DividendReportPdfGenerator { private fun generateCzkSectionPdf(section: CzkDividendSection): ByteArray { val columns = listOf( - PdfColumn("Datum", 1f), PdfColumn("Brutto (Kč)", 1f), PdfColumn("Sražená daň (Kč)", 1f) + PdfColumn("Cenný papír", 1.8f), PdfColumn("Obchodník", 1.5f), + PdfColumn("Datum", 1f), PdfColumn("Brutto (Kč)", 1f), PdfColumn("Sražená daň (Kč)", 1.1f) ) - val rows = section.printableDividendList.map { d -> listOf(d.date, d.brutto, d.tax) } + val rows = section.printableDividendList.map { d -> listOf(d.symbol, d.broker, d.date, d.brutto, d.tax) } val fmt = FormatingHelper::formatDouble val summaryRow = listOf( + SummaryCell.empty(), // Cenný papír + SummaryCell.empty(), // Obchodník SummaryCell.bold("Celkem"), SummaryCell.bold(fmt(section.totalBruttoCrown)), SummaryCell.bold(fmt(section.totalTaxCrown)) diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt index 6a1f9d4..888d994 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt @@ -15,6 +15,11 @@ object DividendXlsxParser { private val DATE_FORMATTER = DateTimeFormat.forPattern("MM/dd/yyyy") + private const val BROKER_NAME = "Morgan Stanley & Co." + + // Description format: "CISCO SYS INC REC 10/22/25 PAY 10/22/25" — the company name precedes " REC ". + private val DESCRIPTION_TAIL = Regex("""\s+(REC|PAY|NON|DIV)\b.*$""") + fun parse(file: File): DividendXlsxResult { return file.inputStream().use { parse(it) } } @@ -38,11 +43,12 @@ object DividendXlsxParser { if (IGNORED_DESCRIPTIONS.any { description.contains(it, ignoreCase = true) }) continue val date = LocalDate.parse(dateStr, DATE_FORMATTER) + val symbol = description.replace(DESCRIPTION_TAIL, "").trim() if (description.contains("WITHHOLDING", ignoreCase = true)) { taxes.add(TaxRecord(date, value)) } else { - dividends.add(DividendRecord(date, value)) + dividends.add(DividendRecord(date, value, symbol = symbol, broker = BROKER_NAME)) } } } diff --git a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt index 3064d4e..b9bbf61 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt @@ -76,6 +76,8 @@ object DividentReportPreparation { printable.add( PrintableDividend( + dividendRecord.symbol, + dividendRecord.broker, DATE_FORMATTER.print(dividendRecord.date), FormatingHelper.formatDouble(dividendRecord.amount), FormatingHelper.formatExchangeRate(exchange), @@ -113,6 +115,8 @@ object DividentReportPreparation { totalTaxCrown += taxRecord.amount printable.add( PrintableCzkDividend( + dividendRecord.symbol, + dividendRecord.broker, DATE_FORMATTER.print(dividendRecord.date), FormatingHelper.formatDouble(dividendRecord.amount), FormatingHelper.formatDouble(taxRecord.amount) diff --git a/src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt b/src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt index 21c3852..154774c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt @@ -21,7 +21,10 @@ object ETradeBenefitHistoryParser { private val PURCHASE_DATE_FORMATTER = DateTimeFormat.forPattern("dd-MMM-yyyy").withLocale(Locale.ENGLISH) private val VEST_DATE_FORMATTER = DateTimeFormat.forPattern("MM/dd/yyyy") + private const val BROKER_NAME = "Morgan Stanley & Co." + private const val COL_RECORD_TYPE = "Record Type" + private const val COL_SYMBOL = "Symbol" private const val COL_PURCHASE_DATE = "Purchase Date" private const val COL_PURCHASE_PRICE = "Purchase Price" @@ -36,6 +39,7 @@ object ETradeBenefitHistoryParser { private const val COL_TAXABLE_GAIN = "Taxable Gain" private const val REC_PURCHASE = "Purchase" + private const val REC_GRANT = "Grant" private const val REC_VEST_SCHEDULE = "Vest Schedule" private const val REC_TAX_WITHHOLDING = "Tax Withholding" @@ -70,7 +74,9 @@ object ETradeBenefitHistoryParser { purchasePrice = num(row, cols, COL_PURCHASE_PRICE), subscriptionFmv = num(row, cols, COL_GRANT_DATE_FMV), purchaseFmv = num(row, cols, COL_PURCHASE_DATE_FMV), - purchaseDate = date + purchaseDate = date, + symbol = str(row, cols, COL_SYMBOL)!!, + broker = BROKER_NAME ) } return out @@ -81,11 +87,18 @@ object ETradeBenefitHistoryParser { // identified by (Grant Number, Vest Period) appearing immediately below. data class Pending(val grantId: String, val period: String, val vestDate: LocalDate, val vestedQty: Int) + val symbolByGrant = mutableMapOf() val out = mutableListOf() var pending: Pending? = null for (i in (sheet.firstRowNum + 1)..sheet.lastRowNum) { val row = sheet.getRow(i) ?: continue when (str(row, cols, COL_RECORD_TYPE)) { + REC_GRANT -> { + pending = null + val grantId = str(row, cols, COL_GRANT_NUMBER) ?: continue + val symbol = str(row, cols, COL_SYMBOL) ?: continue + symbolByGrant[grantId] = symbol + } REC_VEST_SCHEDULE -> { pending = null val vested = optNum(row, cols, COL_VESTED_QTY)?.toInt() ?: 0 @@ -107,7 +120,9 @@ object ETradeBenefitHistoryParser { quantity = p.vestedQty, vestFmv = taxableGain / p.vestedQty, vestDate = p.vestDate, - grantId = p.grantId + grantId = p.grantId, + symbol = symbolByGrant[p.grantId]!!, + broker = BROKER_NAME ) pending = null } diff --git a/src/main/kotlin/cz/solutions/cockroach/ETradeGainLossParser.kt b/src/main/kotlin/cz/solutions/cockroach/ETradeGainLossParser.kt index b7622d1..4ee88f1 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ETradeGainLossParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeGainLossParser.kt @@ -24,6 +24,7 @@ object ETradeGainLossParser { } private fun parseSaleRecord(row: CSVRecord): SaleRecord { + val symbol = row.get(1) val dateSold = parseDate(row.get(12)) val quantity = row.get(3).toDouble() val vestDate = parseDate(row.get(41)) @@ -39,7 +40,9 @@ object ETradeGainLossParser { purchasePrice = adjustedCostBasisPerShare, purchaseFmv = adjustedCostBasisPerShare, purchaseDate = vestDate, - grantId = grantNumber + grantId = grantNumber, + symbol = symbol, + broker = "Morgan Stanley & Co." ) } diff --git a/src/main/kotlin/cz/solutions/cockroach/ETradeGainLossXlsParser.kt b/src/main/kotlin/cz/solutions/cockroach/ETradeGainLossXlsParser.kt index 08456fe..27747cb 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ETradeGainLossXlsParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeGainLossXlsParser.kt @@ -13,6 +13,7 @@ object ETradeGainLossXlsParser { private val DATE_FORMATTER = DateTimeFormat.forPattern("MM/dd/yyyy") private const val COL_RECORD_TYPE = "Record Type" + private const val COL_SYMBOL = "Symbol" private const val COL_PLAN_TYPE = "Plan Type" private const val COL_QUANTITY = "Quantity" private const val COL_ADJUSTED_COST_BASIS_PER_SHARE = "Adjusted Cost Basis Per Share" @@ -63,6 +64,7 @@ object ETradeGainLossXlsParser { } private fun parseSaleRecord(row: Row, columnIndex: Map): SaleRecord { + val symbol = getStringValue(row, columnIndex, COL_SYMBOL).orEmpty() val dateSold = parseDate(requireString(row, columnIndex, COL_DATE_SOLD)) val quantity = getNumericValue(row, columnIndex, COL_QUANTITY) val vestDate = parseDate(requireString(row, columnIndex, COL_VEST_DATE)) @@ -79,7 +81,9 @@ object ETradeGainLossXlsParser { purchasePrice = adjustedCostBasisPerShare, purchaseFmv = adjustedCostBasisPerShare, purchaseDate = vestDate, - grantId = grantNumber + grantId = grantNumber, + symbol = symbol, + broker = "Morgan Stanley & Co." ) } diff --git a/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt index a893a18..2abc593 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt @@ -4,6 +4,11 @@ import java.io.File object EsppPdfParser { + private const val BROKER_NAME = "Charles Schwab & Co." + + // After "Company Name (Symbol)" the symbol appears in parentheses, possibly on the next line. + private val SYMBOL_PATTERN = Regex("""Company Name \(Symbol\)[\s\S]*?\(([A-Z][A-Z0-9.]*)\)""") + /** * Parses a single ESPP Purchase Confirmation PDF and returns an EsppRecord. */ @@ -26,6 +31,7 @@ object EsppPdfParser { val purchasePricePerShare = extractPurchasePricePerShare(text) val grantDateMarketValue = PdfParserUtils.extractDollarAmount(text, "Grant Date Market Value") val purchaseValuePerShare = PdfParserUtils.extractDollarAmount(text, "Purchase Value per Share") + val symbol = SYMBOL_PATTERN.find(text)?.groupValues?.get(1).orEmpty() return EsppRecord( date = purchaseDate, @@ -33,7 +39,9 @@ object EsppPdfParser { purchasePrice = purchasePricePerShare, subscriptionFmv = grantDateMarketValue, purchaseFmv = purchaseValuePerShare, - purchaseDate = purchaseDate + purchaseDate = purchaseDate, + symbol = symbol, + broker = BROKER_NAME ) } diff --git a/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt b/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt index ff1698c..42a0a64 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt @@ -8,5 +8,7 @@ data class EsppRecord( val purchasePrice: Double, val subscriptionFmv: Double, val purchaseFmv: Double, - val purchaseDate: LocalDate + val purchaseDate: LocalDate, + val symbol: String = "", + val broker: String = "", ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/EsppReportPdfGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/EsppReportPdfGenerator.kt index bc7f04e..be70d48 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppReportPdfGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppReportPdfGenerator.kt @@ -2,10 +2,11 @@ package cz.solutions.cockroach object EsppReportPdfGenerator { - fun generate(report: EsppReport, taxableMode: Boolean = false, broker: String = "Charles Schwab & Co., Morgan Stanley & Co."): ByteArray { + fun generate(report: EsppReport, taxableMode: Boolean = false): ByteArray { val fmt = FormatingHelper::formatDouble val baseColumns = listOf( + PdfColumn("Cenný papír", 1f), PdfColumn("Obchodník", 1.3f), PdfColumn("Datum nákupu", 1f), PdfColumn("Počet akcií", 1f), PdfColumn("Zvýh. nákup. cena (USD)", 1.3f), PdfColumn("Tržní cena (USD)", 1f), PdfColumn("Zisk (USD)", 1f), PdfColumn("Kurz D54 (Kč/USD)", 1f), PdfColumn("Zisk (Kč)", 1f) ) @@ -15,11 +16,13 @@ object EsppReportPdfGenerator { val columns = baseColumns + extraColumns val rows = report.printableEsppList.map { e -> - val base = listOf(e.date, fmt(e.amount), e.onePricePurchaseDolarValue, e.onePriceDolarValue, e.buyProfitValue, e.exchange, e.buyCroneProfitValue) + val base = listOf(e.symbol, e.broker, e.date, fmt(e.amount), e.onePricePurchaseDolarValue, e.onePriceDolarValue, e.buyProfitValue, e.exchange, e.buyCroneProfitValue) if (taxableMode) base + listOf(fmt(e.soldAmount), e.taxableBuyCroneProfitValue) else base } val baseSummary = listOf( + SummaryCell.empty(), // Cenný papír + SummaryCell.empty(), // Obchodník SummaryCell.empty(), // Datum nákupu SummaryCell.bold(fmt(report.totalEsppAmount)), // Počet akcií SummaryCell.empty(), // Zvýh. nákup. cena (USD) @@ -36,7 +39,7 @@ object EsppReportPdfGenerator { return PdfReportGenerator.generate(PdfReportDefinition( title = "Nepeněžní příjmy dle §6 ze zahraničí – akciový program pro zaměstnance (§6, odst. 3)", - subtitles = listOf("Program ESPP – nákup akcií za zvýhodněnou cenu", "Cenný papír: Cisco Systems", "Obchodník: $broker"), + subtitles = listOf("Program ESPP – nákup akcií za zvýhodněnou cenu"), columns = columns, rows = rows, summaryRow = summaryRow, landscape = true )) diff --git a/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt index 416d5d3..d8bcd37 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt @@ -42,6 +42,8 @@ object EsppReportPreparation { val partialProfit = espp.purchaseFmv - espp.purchasePrice return EsppInfo( + symbol = espp.symbol, + broker = espp.broker, date = espp.purchaseDate, amount = espp.quantity, exchange = exchange, @@ -56,6 +58,8 @@ object EsppReportPreparation { } private data class EsppInfo( + val symbol: String, + val broker: String, val date: LocalDate, val amount: Double, val exchange: Double, @@ -69,6 +73,8 @@ object EsppReportPreparation { ) { fun toPrintable(): PrintableEspp { return PrintableEspp( + symbol = symbol, + broker = broker, date = DATE_FORMATTER.print(date), amount = amount, exchange = FormatingHelper.formatExchangeRate(exchange), diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt index 806c88b..cd13860 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt @@ -5,5 +5,7 @@ import org.joda.time.LocalDate data class InterestRecord( val date: LocalDate, val amount: Double, - val currency: Currency = Currency.USD + val currency: Currency = Currency.USD, + val product: String = "", + val broker: String = "", ) diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt index ab12c37..4a75191 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt @@ -1,6 +1,8 @@ package cz.solutions.cockroach data class PrintableInterest( + val product: String, + val broker: String, val date: String, val brutto: String, val exchange: String, @@ -8,6 +10,8 @@ data class PrintableInterest( ) data class PrintableCzkInterest( + val product: String, + val broker: String, val date: String, val brutto: String ) diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt index 7665422..72358c7 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt @@ -30,14 +30,17 @@ object InterestReportPdfGenerator { private fun generateCurrencySectionPdf(section: CurrencyInterestSection): ByteArray { val cur = section.currency.name val columns = listOf( - PdfColumn("Datum", 1f), PdfColumn("Brutto ($cur)", 1f), - PdfColumn("Kurz (Kč/$cur)", 1f), PdfColumn("Brutto (Kč)", 1f) + PdfColumn("Produkt", 2f), PdfColumn("Obchodník", 1.5f), + PdfColumn("Datum", 0.9f), PdfColumn("Brutto ($cur)", 0.9f), + PdfColumn("Kurz (Kč/$cur)", 1f), PdfColumn("Brutto (Kč)", 0.9f) ) val rows = section.printableInterestList.map { i -> - listOf(i.date, i.brutto, i.exchange, i.bruttoCrown) + listOf(i.product, i.broker, i.date, i.brutto, i.exchange, i.bruttoCrown) } val fmt = FormatingHelper::formatDouble val summaryRow = listOf( + SummaryCell.empty(), // Produkt + SummaryCell.empty(), // Obchodník SummaryCell.bold("Celkem"), SummaryCell.bold(fmt(section.totalBrutto)), SummaryCell.empty(), @@ -53,11 +56,14 @@ object InterestReportPdfGenerator { private fun generateCzkSectionPdf(section: CzkInterestSection): ByteArray { val columns = listOf( + PdfColumn("Produkt", 2f), PdfColumn("Obchodník", 1.5f), PdfColumn("Datum", 1f), PdfColumn("Brutto (Kč)", 1f) ) - val rows = section.printableInterestList.map { i -> listOf(i.date, i.brutto) } + val rows = section.printableInterestList.map { i -> listOf(i.product, i.broker, i.date, i.brutto) } val fmt = FormatingHelper::formatDouble val summaryRow = listOf( + SummaryCell.empty(), // Produkt + SummaryCell.empty(), // Obchodník SummaryCell.bold("Celkem"), SummaryCell.bold(fmt(section.totalBruttoCrown)) ) diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt index 3cf3fd0..957d308 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt @@ -45,6 +45,8 @@ object InterestReportPreparation { printable.add( PrintableInterest( + interestRecord.product, + interestRecord.broker, DATE_FORMATTER.print(interestRecord.date), FormatingHelper.formatDouble(interestRecord.amount), FormatingHelper.formatExchangeRate(exchange), @@ -65,6 +67,8 @@ object InterestReportPreparation { totalBruttoCrown += interestRecord.amount printable.add( PrintableCzkInterest( + interestRecord.product, + interestRecord.broker, DATE_FORMATTER.print(interestRecord.date), FormatingHelper.formatDouble(interestRecord.amount) ) diff --git a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt index c2efe84..c7c9cac 100644 --- a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt @@ -16,6 +16,8 @@ import org.joda.time.LocalDate import org.joda.time.format.DateTimeFormat import java.util.* +private const val BROKER = "Charles Schwab & Co." + class JsonExportParser { @@ -41,7 +43,9 @@ class JsonExportParser { it.quantity, it.transactionDetails[0].details.vestFairMarketValue, it.transactionDetails[0].details.vestDate, - it.transactionDetails[0].details.awardId + it.transactionDetails[0].details.awardId, + symbol = it.symbol, + broker = BROKER ) }, export.transactions.filterIsInstance().map { @@ -52,13 +56,17 @@ class JsonExportParser { it.transactionDetails[0].details.purchasePrice, it.transactionDetails[0].details.subscriptionFairMarketValue, it.transactionDetails[0].details.purchaseFairMarketValue, - it.transactionDetails[0].details.purchaseDate + it.transactionDetails[0].details.purchaseDate, + symbol = it.symbol, + broker = BROKER ) }, export.transactions.filterIsInstance().map { DividendRecord( it.date, - it.amount + it.amount, + symbol = it.symbol, + broker = BROKER ) }, export.transactions.filterIsInstance().map { @@ -84,7 +92,9 @@ class JsonExportParser { transactionDetail.details.purchasePrice(), transactionDetail.details.purchaseFmv(), transactionDetail.details.purchaseDate(), - transactionDetail.details.grantId() + transactionDetail.details.grantId(), + symbol = it.symbol, + broker = BROKER ) } @@ -143,7 +153,8 @@ sealed class Transaction { @Contextual override val date: LocalDate, @Contextual - val amount: Double + val amount: Double, + val symbol: String, ) : Transaction() @Serializable @@ -152,6 +163,7 @@ sealed class Transaction { override val date: LocalDate, @Contextual val amount: Double, + val symbol: String, val transactionDetails: List ) : Transaction() @@ -162,6 +174,7 @@ sealed class Transaction { override val date: LocalDate, val quantity: Int, + val symbol: String, val transactionDetails: List ) : Transaction() @@ -172,6 +185,7 @@ sealed class Transaction { override val date: LocalDate, val quantity: Int, + val symbol: String, val transactionDetails: List ) : Transaction() diff --git a/src/main/kotlin/cz/solutions/cockroach/PdfReportGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/PdfReportGenerator.kt index f8110dc..9dc7933 100644 --- a/src/main/kotlin/cz/solutions/cockroach/PdfReportGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/PdfReportGenerator.kt @@ -137,10 +137,29 @@ object PdfReportGenerator { return PDType0Font.load(doc, stream) } + private const val CELL_PADDING = 2f + private const val ELLIPSIS = "…" + private fun drawText(cs: PDPageContentStream, font: PDFont, size: Float, x: Float, y: Float, text: String) { cs.beginText(); cs.setFont(font, size); cs.newLineAtOffset(x, y); cs.showText(text); cs.endText() } + /** Returns [text] shortened with an ellipsis so it fits within [maxWidth] when rendered with [font]/[size]. */ + private fun fitText(text: String, font: PDFont, size: Float, maxWidth: Float): String { + if (text.isEmpty() || maxWidth <= 0f) return "" + val fullWidth = font.getStringWidth(text) / 1000f * size + if (fullWidth <= maxWidth) return text + val ellipsisWidth = font.getStringWidth(ELLIPSIS) / 1000f * size + if (ellipsisWidth > maxWidth) return "" + var lo = 0; var hi = text.length + while (lo < hi) { + val mid = (lo + hi + 1) / 2 + val w = font.getStringWidth(text.substring(0, mid)) / 1000f * size + ellipsisWidth + if (w <= maxWidth) lo = mid else hi = mid - 1 + } + return text.substring(0, lo) + ELLIPSIS + } + private fun drawGroupHeaderRow(cs: PDPageContentStream, font: PDFont, groups: List, columnWidths: List, yPos: Float) { val totalWidth = columnWidths.sum() var xPos = MARGIN @@ -152,8 +171,9 @@ object PdfReportGenerator { cs.addRect(xPos, yPos - 3f, spanWidth, ROW_HEIGHT) cs.fill() cs.setNonStrokingColor(0f, 0f, 0f) - val textWidth = font.getStringWidth(group.label) / 1000f * TABLE_FONT_SIZE - drawText(cs, font, TABLE_FONT_SIZE, xPos + (spanWidth - textWidth) / 2f, yPos, group.label) + val label = fitText(group.label, font, TABLE_FONT_SIZE, spanWidth - CELL_PADDING * 2f) + val textWidth = font.getStringWidth(label) / 1000f * TABLE_FONT_SIZE + drawText(cs, font, TABLE_FONT_SIZE, xPos + (spanWidth - textWidth) / 2f, yPos, label) } xPos += spanWidth colIndex += group.colspan @@ -165,13 +185,22 @@ object PdfReportGenerator { val totalWidth = columnWidths.sum() cs.setNonStrokingColor(0.85f, 0.85f, 0.85f); cs.addRect(MARGIN, yPos - 3f, totalWidth, ROW_HEIGHT); cs.fill(); cs.setNonStrokingColor(0f, 0f, 0f) var xPos = MARGIN - for ((i, col) in columns.withIndex()) { drawText(cs, font, TABLE_FONT_SIZE, xPos + 2f, yPos, col.name); xPos += columnWidths[i] } + for ((i, col) in columns.withIndex()) { + val maxW = columnWidths[i] - CELL_PADDING * 2f + drawText(cs, font, TABLE_FONT_SIZE, xPos + CELL_PADDING, yPos, fitText(col.name, font, TABLE_FONT_SIZE, maxW)) + xPos += columnWidths[i] + } cs.moveTo(MARGIN, yPos - 3f); cs.lineTo(MARGIN + totalWidth, yPos - 3f); cs.stroke() } private fun drawTableRow(cs: PDPageContentStream, font: PDFont, columnWidths: List, data: List, yPos: Float) { var xPos = MARGIN - for ((i, width) in columnWidths.withIndex()) { drawText(cs, font, TABLE_FONT_SIZE, xPos + 2f, yPos, if (i < data.size) data[i] else ""); xPos += width } + for ((i, width) in columnWidths.withIndex()) { + val text = if (i < data.size) data[i] else "" + val maxW = width - CELL_PADDING * 2f + drawText(cs, font, TABLE_FONT_SIZE, xPos + CELL_PADDING, yPos, fitText(text, font, TABLE_FONT_SIZE, maxW)) + xPos += width + } } private fun drawSummaryRow(cs: PDPageContentStream, regularFont: PDFont, boldFont: PDFont, columnWidths: List, data: List, yPos: Float) { @@ -179,7 +208,8 @@ object PdfReportGenerator { for ((i, width) in columnWidths.withIndex()) { val cell = if (i < data.size) data[i] else SummaryCell.empty() val font = if (cell.bold) boldFont else regularFont - drawText(cs, font, TABLE_FONT_SIZE, xPos + 2f, yPos, cell.text) + val maxW = width - CELL_PADDING * 2f + drawText(cs, font, TABLE_FONT_SIZE, xPos + CELL_PADDING, yPos, fitText(cell.text, font, TABLE_FONT_SIZE, maxW)) xPos += width } } diff --git a/src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt b/src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt index 2428483..75c9821 100644 --- a/src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt @@ -1,6 +1,8 @@ package cz.solutions.cockroach data class PrintableCzkDividend( + val symbol: String, + val broker: String, val date: String, val brutto: String, val tax: String diff --git a/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt b/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt index 56effbd..fbd73fc 100644 --- a/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt @@ -1,6 +1,8 @@ package cz.solutions.cockroach data class PrintableDividend( + val symbol: String, + val broker: String, val date: String, val brutto: String, val exchange: String, diff --git a/src/main/kotlin/cz/solutions/cockroach/PrintableEspp.kt b/src/main/kotlin/cz/solutions/cockroach/PrintableEspp.kt index 0100c47..8839316 100644 --- a/src/main/kotlin/cz/solutions/cockroach/PrintableEspp.kt +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableEspp.kt @@ -1,6 +1,8 @@ package cz.solutions.cockroach data class PrintableEspp( + val symbol: String, + val broker: String, val date: String, val amount: Double, val exchange: String, diff --git a/src/main/kotlin/cz/solutions/cockroach/PrintableRsu.kt b/src/main/kotlin/cz/solutions/cockroach/PrintableRsu.kt index 5cae85d..43facdb 100644 --- a/src/main/kotlin/cz/solutions/cockroach/PrintableRsu.kt +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableRsu.kt @@ -1,6 +1,8 @@ package cz.solutions.cockroach data class PrintableRsu( + val symbol: String, + val broker: String, val date: String, val amount: Int, val exchange: String, diff --git a/src/main/kotlin/cz/solutions/cockroach/PrintableSale.kt b/src/main/kotlin/cz/solutions/cockroach/PrintableSale.kt index 9b4cf58..1b7d48a 100644 --- a/src/main/kotlin/cz/solutions/cockroach/PrintableSale.kt +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableSale.kt @@ -1,6 +1,9 @@ package cz.solutions.cockroach data class PrintableSale( + val symbol: String, + val broker: String, + val amount: String, diff --git a/src/main/kotlin/cz/solutions/cockroach/Report.kt b/src/main/kotlin/cz/solutions/cockroach/Report.kt index eb5dec0..a61f1f1 100644 --- a/src/main/kotlin/cz/solutions/cockroach/Report.kt +++ b/src/main/kotlin/cz/solutions/cockroach/Report.kt @@ -21,7 +21,7 @@ class Report( fun getRsuPdf(): ByteArray = RsuReportPdfGenerator.generate(rsuReport) - fun getRsu2024Pdf(): ByteArray = RsuReportPdfGenerator.generate(rsuReport2024, taxableMode = true, broker = "Charles Schwab & Co.") + fun getRsu2024Pdf(): ByteArray = RsuReportPdfGenerator.generate(rsuReport2024, taxableMode = true) fun getDividendPdf(): ByteArray = DividendReportPdfGenerator.generate(dividendReport) @@ -29,7 +29,7 @@ class Report( fun getEsppPdf(): ByteArray = EsppReportPdfGenerator.generate(esppReport) - fun getEspp2024Pdf(): ByteArray = EsppReportPdfGenerator.generate(esppReport2024, taxableMode = true, broker = "Charles Schwab & Co.") + fun getEspp2024Pdf(): ByteArray = EsppReportPdfGenerator.generate(esppReport2024, taxableMode = true) fun getSalesPdf(): ByteArray = SalesReportPdfGenerator.generate(salesReport) diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt index d5bb3ae..f231c4f 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -29,8 +29,13 @@ object RevolutParser { private const val STOCKS_TYPE_DIVIDEND = "DIVIDEND" private const val STOCKS_TYPE_DIVIDEND_TAX_CORRECTION = "DIVIDEND TAX (CORRECTION)" + private const val BROKER_NAME = "Revolut" + private val SAVINGS_DATE_FORMATTER = DateTimeFormat.forPattern("MMM d, yyyy, h:mm:ss a").withLocale(Locale.US) + // ISIN: 2-letter country code + 9 alphanumeric chars + 1 check digit (12 chars total). + private val ISIN_PATTERN = Regex("\\b([A-Z]{2}[A-Z0-9]{9}[0-9])\\b") + fun parseStocks(file: File, whtRate: Double = DEFAULT_WHT_RATE): RevolutStocksParseResult { return file.reader(StandardCharsets.UTF_8).use { parseStocks(it, whtRate) } } @@ -57,7 +62,8 @@ object RevolutParser { } val gross = amount / (1.0 - whtRate) val wht = gross - amount - dividends.add(DividendRecord(date, gross, currency)) + val ticker = record.get("Ticker").trim() + dividends.add(DividendRecord(date, gross, currency, symbol = ticker, broker = BROKER_NAME)) if (wht > 0.0) { taxes.add(TaxRecord(date, -wht, currency)) } @@ -103,19 +109,23 @@ object RevolutParser { for (record in parser) { val description = record.get("Description").trim() val rawValue = record.get(valueColumn).trim() - if (rawValue.isEmpty()) continue - val value = rawValue.replace(",", "").toDoubleOrNull() ?: continue - val date = parseSavingsDate(record.get("Date")) - when { - description.startsWith("Interest PAID") -> { - if (value > 0.0) interestRecords.add(InterestRecord(date, value, currency)) - } - description.startsWith("Service Fee Charged") -> { - feeTotal += value - feeCount++ - feeCurrency = currency + if (!rawValue.isEmpty()){ + val value = rawValue.replace(",", "").toDoubleOrNull() ?: continue + val date = parseSavingsDate(record.get("Date")) + when { + description.startsWith("Interest PAID") -> { + if (value > 0.0) { + val product = ISIN_PATTERN.find(description)?.value ?: description + interestRecords.add(InterestRecord(date, value, currency, product = product, broker = BROKER_NAME)) + } + } + description.startsWith("Service Fee Charged") -> { + feeTotal += value + feeCount++ + feeCurrency = currency + } + else -> { /* Interest Reinvested, BUY, SELL: ignored */ } } - else -> { /* Interest Reinvested, BUY, SELL: ignored */ } } } } diff --git a/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt index 149ede1..72404a9 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt @@ -4,6 +4,11 @@ import java.io.File object RsuPdfParser { + private const val BROKER_NAME = "Charles Schwab & Co." + + // After "Company Name (Symbol)" the symbol appears in parentheses, possibly on the next line. + private val SYMBOL_PATTERN = Regex("""Company Name \(Symbol\)[\s\S]*?\(([A-Z][A-Z0-9.]*)\)""") + /** * Parses a single RSU Release Confirmation PDF and returns an RsuRecord. */ @@ -25,13 +30,16 @@ object RsuPdfParser { val sharesReleased = PdfParserUtils.extractInt(text, "Shares Released") val marketValuePerShare = PdfParserUtils.extractDollarAmount(text, "Market Value Per Share") val awardNumber = PdfParserUtils.extractString(text, "Award Number") + val symbol = SYMBOL_PATTERN.find(text)?.groupValues?.get(1).orEmpty() return RsuRecord( date = releaseDate, quantity = sharesReleased, vestFmv = marketValuePerShare, vestDate = releaseDate, - grantId = awardNumber + grantId = awardNumber, + symbol = symbol, + broker = BROKER_NAME ) } } diff --git a/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt b/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt index 3cd6617..35b339a 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt @@ -7,5 +7,7 @@ data class RsuRecord( val quantity: Int, val vestFmv: Double, val vestDate: LocalDate, - val grantId: String + val grantId: String, + val symbol: String = "", + val broker: String = "", ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/RsuReportPdfGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/RsuReportPdfGenerator.kt index 421c4ca..1ec54d5 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuReportPdfGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuReportPdfGenerator.kt @@ -2,10 +2,11 @@ package cz.solutions.cockroach object RsuReportPdfGenerator { - fun generate(report: RsuReport, taxableMode: Boolean = false, broker: String = "Charles Schwab & Co., Morgan Stanley & Co."): ByteArray { + fun generate(report: RsuReport, taxableMode: Boolean = false): ByteArray { val fmt = FormatingHelper::formatDouble val baseColumns = listOf( + PdfColumn("Cenný papír", 1f), PdfColumn("Obchodník", 1.3f), PdfColumn("Datum připsání", 1f), PdfColumn("Počet akcií", 1f), PdfColumn("Tržní cena (USD)", 1f), PdfColumn("Zisk (USD)", 1f), PdfColumn("Kurz D54 (Kč/USD)", 1f), PdfColumn("Zisk (Kč)", 1f) ) @@ -15,11 +16,13 @@ object RsuReportPdfGenerator { val columns = baseColumns + extraColumns val rows = report.printableRsuList.map { r -> - val base = listOf(r.date, r.amount.toString(), r.onePriceDolarValue, r.vestDolarValue, r.exchange, r.vestCroneValue) + val base = listOf(r.symbol, r.broker, r.date, r.amount.toString(), r.onePriceDolarValue, r.vestDolarValue, r.exchange, r.vestCroneValue) if (taxableMode) base + listOf(r.soldAmount, r.taxableVestCroneValue) else base } val baseSummary = listOf( + SummaryCell.empty(), // Cenný papír + SummaryCell.empty(), // Obchodník SummaryCell.empty(), // Datum připsání SummaryCell.bold(report.totalAmount.toString()), // Počet akcií SummaryCell.empty(), // Tržní cena (USD) @@ -35,7 +38,7 @@ object RsuReportPdfGenerator { return PdfReportGenerator.generate(PdfReportDefinition( title = "Nepeněžní příjmy dle §6 ze zahraničí – akciový program pro zaměstnance (§6, odst. 3)", - subtitles = listOf("Program RS – bezplatné poskytnutí akcií", "Cenný papír: Cisco Systems", "Obchodník: $broker"), + subtitles = listOf("Program RS – bezplatné poskytnutí akcií"), columns = columns, rows = rows, summaryRow = summaryRow, landscape = true )) diff --git a/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt index 6835f03..677e442 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt @@ -44,6 +44,8 @@ object RsuReportPreparation { val taxableVestCroneValue = taxableAmount * rsu.vestFmv * exchange return RsuInfo( + symbol = rsu.symbol, + broker = rsu.broker, date = rsu.vestDate, amount = rsu.quantity, exchange = exchange, @@ -56,6 +58,8 @@ object RsuReportPreparation { } private data class RsuInfo( + val symbol: String, + val broker: String, val date: LocalDate, val amount: Int, val exchange: Double, @@ -67,6 +71,8 @@ object RsuReportPreparation { ) { fun toPrintable(): PrintableRsu { return PrintableRsu( + symbol = symbol, + broker = broker, date = DATE_FORMATTER.print(date), amount = amount, exchange = FormatingHelper.formatExchangeRate(exchange), diff --git a/src/main/kotlin/cz/solutions/cockroach/SaleRecord.kt b/src/main/kotlin/cz/solutions/cockroach/SaleRecord.kt index 2edccbc..529a58e 100644 --- a/src/main/kotlin/cz/solutions/cockroach/SaleRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/SaleRecord.kt @@ -10,7 +10,9 @@ data class SaleRecord( val purchasePrice: Double, val purchaseFmv: Double, val purchaseDate: LocalDate, - val grantId: String? + val grantId: String?, + val symbol: String = "", + val broker: String = "", ) { fun isTaxable(): Boolean { return date.isBefore(purchaseDate.plusYears(3)) diff --git a/src/main/kotlin/cz/solutions/cockroach/SalesReportPdfGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/SalesReportPdfGenerator.kt index 0962069..a42cdb1 100644 --- a/src/main/kotlin/cz/solutions/cockroach/SalesReportPdfGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/SalesReportPdfGenerator.kt @@ -5,12 +5,16 @@ object SalesReportPdfGenerator { fun generate(salesReport: SalesReport): ByteArray { val groupHeaders = listOf( + ColumnGroupHeader("Položka", 2), ColumnGroupHeader("Počet", 1), ColumnGroupHeader("Nákup", 6), ColumnGroupHeader("Prodej", 6), ColumnGroupHeader("Zisk", 2) ) val columns = listOf( + PdfColumn("Cenný papír", 70f), + PdfColumn("Obchodník", 110f), + PdfColumn("# akcií", 50f), PdfColumn("Datum", 68f), @@ -33,6 +37,9 @@ object SalesReportPdfGenerator { val rows = salesReport.printableSalesList.map { sale -> listOf( + sale.symbol, + sale.broker, + sale.amount, sale.purchaseDate, @@ -56,6 +63,8 @@ object SalesReportPdfGenerator { val fmt = FormatingHelper::formatDouble val summaryRow = listOf( + SummaryCell.empty(), // Cenný papír + SummaryCell.empty(), // Obchodník SummaryCell.regular(fmt(salesReport.totalAmount)), // # akcií SummaryCell.empty(), // Nákup: Datum SummaryCell.empty(), // Nákup: Cena ($) @@ -84,9 +93,6 @@ object SalesReportPdfGenerator { val definition = PdfReportDefinition( title = "Ostatní příjmy dle §10 ze zahraničí – zisk z prodeje akcií", - subtitles = listOf( - "Cenný papír: Cisco Systems | Obchodník: Charles Schwab & Co., Morgan Stanley & Co." - ), columnGroupHeaders = groupHeaders, columns = columns, rows = rows, diff --git a/src/main/kotlin/cz/solutions/cockroach/SalesReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/SalesReportPreparation.kt index 3d11224..6533140 100644 --- a/src/main/kotlin/cz/solutions/cockroach/SalesReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/SalesReportPreparation.kt @@ -55,6 +55,8 @@ object SalesReportPreparation { totalAmount += sale.quantity PrintableSale( + symbol = sale.symbol, + broker = sale.broker, amount = FormatingHelper.formatDouble(sale.quantity), purchaseDate = DATE_FORMATTER.print(sale.purchaseDate), diff --git a/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt index f028bec..7397de8 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt @@ -40,7 +40,7 @@ class DegiroAccountStatementParserTest { val result = DegiroAccountStatementParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2024, 3, 15), 12.34, Currency.USD) + DividendRecord(LocalDate(2024, 3, 15), 12.34, Currency.USD, symbol = "APPLE INC", broker = "Degiro") ) assertThat(result.taxRecords).containsExactly( TaxRecord(LocalDate(2024, 3, 15), -1.85, Currency.USD) @@ -58,7 +58,7 @@ class DegiroAccountStatementParserTest { val result = DegiroAccountStatementParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2024, 3, 15), 10.00, Currency.USD) + DividendRecord(LocalDate(2024, 3, 15), 10.00, Currency.USD, symbol = "APPLE INC", broker = "Degiro") ) } @@ -74,8 +74,8 @@ class DegiroAccountStatementParserTest { val result = DegiroAccountStatementParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2024, 4, 1), 5.00, Currency.EUR), - DividendRecord(LocalDate(2024, 4, 2), 1234.56, Currency.CZK) + DividendRecord(LocalDate(2024, 4, 1), 5.00, Currency.EUR, symbol = "ASML HOLDING", broker = "Degiro"), + DividendRecord(LocalDate(2024, 4, 2), 1234.56, Currency.CZK, symbol = "CEZ AS", broker = "Degiro") ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt index e5bd6f2..8afdeb5 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt @@ -20,7 +20,7 @@ class DividendXlsxParserTest { val result = DividendXlsxParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2025, 10, 22), 58.22) + DividendRecord(LocalDate(2025, 10, 22), 58.22, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.") ) assertThat(result.taxRecords).containsExactly( TaxRecord(LocalDate(2025, 10, 22), -8.73) @@ -50,7 +50,7 @@ class DividendXlsxParserTest { val result = DividendXlsxParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2025, 10, 22), 58.22) + DividendRecord(LocalDate(2025, 10, 22), 58.22, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.") ) assertThat(result.taxRecords).containsExactly( TaxRecord(LocalDate(2025, 10, 22), -8.73) diff --git a/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt index 91f4c71..9d594a3 100644 --- a/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt @@ -27,6 +27,8 @@ class ETradeBenefitHistoryParserTest { assertThat(purchase2025Q1.purchasePrice).isEqualTo(42.143, within(EPS)) assertThat(purchase2025Q1.subscriptionFmv).isEqualTo(49.58, within(EPS)) assertThat(purchase2025Q1.purchaseFmv).isEqualTo(50.92, within(EPS)) + assertThat(purchase2025Q1.symbol).isNotBlank() + assertThat(purchase2025Q1.broker).isEqualTo("Morgan Stanley & Co.") val purchase2025Q3 = result.esppRecords.single { it.purchaseDate == LocalDate(2025, 9, 15) } assertThat(purchase2025Q3.purchaseFmv).isEqualTo(86.76, within(EPS)) @@ -46,6 +48,10 @@ class ETradeBenefitHistoryParserTest { val grant82769 = result.rsuRecords.filter { it.grantId == "3-82769" } assertThat(grant82769).hasSize(4) + assertThat(grant82769).allSatisfy { record -> + assertThat(record.symbol).isNotBlank() + assertThat(record.broker).isEqualTo("Morgan Stanley & Co.") + } assertThat(grant82769.first { it.vestDate == LocalDate(2025, 6, 20) }) .satisfies({ assertThat(it.quantity).isEqualTo(66) }, { assertThat(it.vestFmv).isEqualTo(52.870, within(EPS)) }) diff --git a/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt index 29ca12a..5a9a9c8 100644 --- a/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt @@ -26,7 +26,9 @@ class ETradeGainLossParserTest { 50.00, 50.00, LocalDate(2025, 3, 15), - "9990001" + "9990001", + symbol = "ACME", + broker = "Morgan Stanley & Co." ) ) @@ -39,7 +41,9 @@ class ETradeGainLossParserTest { 60.00, 60.00, LocalDate(2025, 3, 15), - "9990002" + "9990002", + symbol = "ACME", + broker = "Morgan Stanley & Co." ) ) @@ -52,7 +56,9 @@ class ETradeGainLossParserTest { 70.00, 70.00, LocalDate(2025, 6, 10), - "9990001" + "9990001", + symbol = "ACME", + broker = "Morgan Stanley & Co." ) ) diff --git a/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossXlsParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossXlsParserTest.kt index c959d2f..5bbfa7d 100644 --- a/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossXlsParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossXlsParserTest.kt @@ -22,13 +22,16 @@ class ETradeGainLossXlsParserTest { assertThat(result[0]).isEqualTo( SaleRecord( LocalDate(2026, 2, 9), "RS", 25.0, 86.7548, 71.79, 71.79, - LocalDate(2025, 8, 10), "1538646" + LocalDate(2025, 8, 10), "1538646", + symbol = "CSCO", + broker = "Morgan Stanley & Co.", ) ) assertThat(result[1]).isEqualTo( SaleRecord( LocalDate(2026, 2, 9), "RS", 47.0, 86.754894, 71.79, 71.79, - LocalDate(2025, 8, 10), "1642365" + LocalDate(2025, 8, 10), "1642365", + symbol = "CSCO", broker = "Morgan Stanley & Co." ) ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/EsppPdfParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/EsppPdfParserTest.kt index 7011067..839d438 100644 --- a/src/test/kotlin/cz/solutions/cockroach/EsppPdfParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/EsppPdfParserTest.kt @@ -42,7 +42,9 @@ class EsppPdfParserTest { purchasePrice = 40.392, subscriptionFmv = 47.52, purchaseFmv = 77.03, - purchaseDate = LocalDate(2025, 12, 31) + purchaseDate = LocalDate(2025, 12, 31), + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ) } @@ -77,7 +79,9 @@ class EsppPdfParserTest { purchasePrice = 46.75, subscriptionFmv = 55.0, purchaseFmv = 65.5, - purchaseDate = LocalDate(2025, 6, 30) + purchaseDate = LocalDate(2025, 6, 30), + symbol = "ACME", + broker = "Charles Schwab & Co." ) ) } @@ -95,7 +99,9 @@ class EsppPdfParserTest { purchasePrice = 40.392, subscriptionFmv = 47.52, purchaseFmv = 77.03, - purchaseDate = LocalDate(2025, 12, 31) + purchaseDate = LocalDate(2025, 12, 31), + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt index 61274f0..df97793 100644 --- a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt @@ -23,7 +23,9 @@ class JsonExportParserTest { 2, 48.38, LocalDate(2023,12,10), - "1461994" + "1461994", + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ), listOf( @@ -34,13 +36,17 @@ class JsonExportParserTest { 42.60, 50.52, LocalDate(2023,12,20), + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ), listOf( DividendRecord( - LocalDate(2023,10,25), - 84.38 - ) + LocalDate(2023,10,25), + 84.38, + symbol = "CSCO", + broker = "Charles Schwab & Co." + ) ), listOf( TaxRecord( @@ -63,7 +69,9 @@ class JsonExportParserTest { 43.91, 43.91, LocalDate(2022,11,10), - "1538646" + "1538646", + symbol = "CSCO", + broker = "Charles Schwab & Co." ), SaleRecord( LocalDate(2023,1,23), @@ -73,7 +81,9 @@ class JsonExportParserTest { 37.366, 53.00, LocalDate(2023,1,10), - null + null, + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ), listOf( diff --git a/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt index 405261f..b29fe74 100644 --- a/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt @@ -36,7 +36,9 @@ class RsuPdfParserTest { quantity = 53, vestFmv = 79.51, vestDate = LocalDate(2025, 12, 10), - grantId = "1623675" + grantId = "1623675", + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ) } @@ -66,7 +68,9 @@ class RsuPdfParserTest { quantity = 18, vestFmv = 67.34, vestDate = LocalDate(2025, 9, 10), - grantId = "1679633" + grantId = "1679633", + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ) } @@ -83,7 +87,9 @@ class RsuPdfParserTest { quantity = 53, vestFmv = 79.51, vestDate = LocalDate(2025, 12, 10), - grantId = "1623675" + grantId = "1623675", + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ) } @@ -107,7 +113,9 @@ class RsuPdfParserTest { quantity = 53, vestFmv = 79.51, vestDate = LocalDate(2025, 12, 10), - grantId = "1623675" + grantId = "1623675", + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ) } From 2c9ce8ebf827952932bd18a6083d5444fe6891dc Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 16:47:53 +0200 Subject: [PATCH 05/31] support etoro and VUB --- .../cz/solutions/cockroach/CockroachConfig.kt | 4 +- .../cz/solutions/cockroach/CockroachMain.kt | 40 +++- .../cz/solutions/cockroach/EtoroXlsxParser.kt | 131 +++++++++++++ .../cockroach/VubInterestPdfParser.kt | 112 +++++++++++ .../cockroach/EtoroXlsxParserTest.kt | 185 ++++++++++++++++++ 5 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt create mode 100644 src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt index 77b94d8..3aa1b6b 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt @@ -13,7 +13,9 @@ data class CockroachConfig( val etrade: String? = null, val etradeBenefitHistory: String? = null, val degiro: List = emptyList(), - val revolut: RevolutConfig = RevolutConfig() + val revolut: RevolutConfig = RevolutConfig(), + val etoro: List = emptyList(), + val vub: List = emptyList() ) { companion object { fun load(file: File): CockroachConfig { diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index 2a16bcd..e6b55b3 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -17,6 +17,8 @@ fun main(args: Array) { revolutStocksFiles = config.revolut.stocks.map { File(it) }, revolutSavingsFiles = config.revolut.savings.map { File(it) }, revolutWhtRate = config.revolut.whtRate, + etoroFiles = config.etoro.map { File(it) }, + vubFiles = config.vub.map { File(it) }, ) return } @@ -51,7 +53,9 @@ object CockroachMain { degiroFiles: List = emptyList(), revolutStocksFiles: List = emptyList(), revolutSavingsFiles: List = emptyList(), - revolutWhtRate: Double = RevolutParser.DEFAULT_WHT_RATE + revolutWhtRate: Double = RevolutParser.DEFAULT_WHT_RATE, + etoroFiles: List = emptyList(), + vubFiles: List = emptyList() ) { val schwabExport = schwabExportFile?.let { parseExportFile(it) } ?: ParsedExport.empty() val eTradeExport = if (eTradeDir != null || eTradeBenefitHistoryFile != null) { @@ -62,9 +66,12 @@ object CockroachMain { .fold(ParsedExport.empty()) { acc, e -> acc + e } val revolutSavingsExport = revolutSavingsFiles.map { parseRevolutSavingsFile(it) } .fold(ParsedExport.empty()) { acc, e -> acc + e } - val parsedExport = schwabExport + eTradeExport + degiroExport + revolutStocksExport + revolutSavingsExport + val etoroExport = etoroFiles.map { parseEtoroFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } + val vubExport = vubFiles.map { parseVubFile(it, year) }.fold(ParsedExport.empty()) { acc, e -> acc + e } + val parsedExport = schwabExport + eTradeExport + degiroExport + + revolutStocksExport + revolutSavingsExport + etoroExport + vubExport require(parsedExport != ParsedExport.empty()) { - "No input sources provided. Specify at least one of: schwab, etrade, degiro, revolut." + "No input sources provided. Specify at least one of: schwab, etrade, degiro, revolut, etoro, vub." } val dailyRateProvider = TabularExchangeRateProvider.fromSource( HttpCnbYearRatesSource(HttpCnbYearRatesSource.defaultCacheDir()), @@ -146,6 +153,33 @@ object CockroachMain { ) } + private fun parseEtoroFile(file: File): ParsedExport { + val result = EtoroXlsxParser.parse(file) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = result.dividendRecords, + taxRecords = result.taxRecords, + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList() + ) + } + + private fun parseVubFile(file: File, year: Int): ParsedExport { + val interestRecords = VubInterestPdfParser.parse(file, year) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = emptyList(), + taxRecords = emptyList(), + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList(), + interestRecords = interestRecords + ) + } + private fun parseETradeDir(eTradeDir: File?, benefitHistoryFile: File? = null): ParsedExport { val benefitHistory = benefitHistoryFile?.let { ETradeBenefitHistoryParser.parse(it) } val rsuRecords = benefitHistory?.rsuRecords diff --git a/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt new file mode 100644 index 0000000..e58156f --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt @@ -0,0 +1,131 @@ +package cz.solutions.cockroach + +import org.joda.time.LocalDate +import org.joda.time.format.DateTimeFormat +import java.io.File +import java.util.logging.Logger +import java.util.zip.ZipFile + +data class EtoroParseResult( + val dividendRecords: List, + val taxRecords: List +) + +/** + * Parses an eToro account-statement XLSX. The "Dividends" sheet (sheet4) lists + * one row per paid dividend with the net amount already received plus the + * withholding tax that was deducted at source. Each row is converted to a + * gross [DividendRecord] (= net + WHT) and a negative [TaxRecord] (= -WHT). + * + * The parser bypasses Apache POI because eToro's workbook declares the main + * spreadsheetml namespace under the "x:" prefix, which POI's XSSF reader + * rejects. Instead we read the underlying ZIP entries (sharedStrings.xml and + * worksheets/sheet4.xml) directly and extract values by regex. + */ +object EtoroXlsxParser { + + private val LOGGER = Logger.getLogger(EtoroXlsxParser::class.java.name) + private const val BROKER_NAME = "eToro" + private const val DIVIDENDS_SHEET = "xl/worksheets/sheet4.xml" + private const val SHARED_STRINGS = "xl/sharedStrings.xml" + private val DATE_FORMATTER = DateTimeFormat.forPattern("dd/MM/yyyy") + + // Header row uses the text labels below; validated so we fail fast if eToro + // changes the column layout. + private const val EXPECTED_DATE_HEADER = "Date of Payment" + private const val EXPECTED_NAME_HEADER = "Instrument Name" + private const val EXPECTED_NET_HEADER = "Net Dividend Received" + private const val EXPECTED_WHT_HEADER = "Withholding Tax Amount" + + private val SHARED_STRING_PATTERN = Regex("""]*>([^<]*)""") + private val ROW_PATTERN = Regex("""]*r="(\d+)"[^>]*>(.*?)
""") + private val CELL_PATTERN = Regex("""]*?)\s*/?>(?:]*>([^<]*))?""") + private val ATTR_R_PATTERN = Regex("""\br="([A-Z]+)\d+"""") + private val ATTR_T_PATTERN = Regex("""\bt="([a-z]+)"""") + + fun parse(file: File): EtoroParseResult { + ZipFile(file).use { zip -> + val strings = readSharedStrings(zip) + val sheetEntry = zip.getEntry(DIVIDENDS_SHEET) + ?: error("eToro XLSX is missing the dividends sheet ($DIVIDENDS_SHEET) in ${file.name}") + val sheetXml = zip.getInputStream(sheetEntry).bufferedReader(Charsets.UTF_8).readText() + return parseSheet(sheetXml, strings, file.name) + } + } + + private fun readSharedStrings(zip: ZipFile): List { + val entry = zip.getEntry(SHARED_STRINGS) ?: return emptyList() + val xml = zip.getInputStream(entry).bufferedReader(Charsets.UTF_8).readText() + return SHARED_STRING_PATTERN.findAll(xml).map { decodeXmlEntities(it.groupValues[1]) }.toList() + } + + private fun parseSheet(xml: String, strings: List, fileName: String): EtoroParseResult { + val dividends = mutableListOf() + val taxes = mutableListOf() + + val rows = ROW_PATTERN.findAll(xml).toList() + require(rows.isNotEmpty()) { "eToro dividends sheet is empty in $fileName" } + + val header = readRow(rows.first().groupValues[2], strings) + validateHeader(header, fileName) + + for (rowMatch in rows.drop(1)) { + val rowNum = rowMatch.groupValues[1].toInt() + val cells = readRow(rowMatch.groupValues[2], strings) + val dateStr = cells["A"] ?: continue + val instrument = cells["B"].orEmpty().ifBlank { cells["L"].orEmpty() } + val netStr = cells["C"] ?: continue + val whtStr = cells["I"].orEmpty() + + val date = LocalDate.parse(dateStr, DATE_FORMATTER) + val net = netStr.toDouble() + val wht = whtStr.toDoubleOrNull() ?: 0.0 + val gross = net + wht + if (gross <= 0.0) { + LOGGER.warning("eToro: skipping non-positive dividend row $rowNum in $fileName (net=$net wht=$wht)") + continue + } + dividends.add(DividendRecord(date, gross, Currency.USD, symbol = instrument, broker = BROKER_NAME)) + if (wht > 0.0) { + taxes.add(TaxRecord(date, -wht, Currency.USD)) + } + } + LOGGER.info("eToro: parsed ${dividends.size} dividend(s) and ${taxes.size} withholding tax record(s) from $fileName") + return EtoroParseResult(dividends, taxes) + } + + private fun readRow(rowBody: String, strings: List): Map { + val out = LinkedHashMap() + for (m in CELL_PATTERN.findAll(rowBody)) { + val attrs = m.groupValues[1] + val raw = m.groupValues[2] + if (raw.isEmpty()) continue + val ref = ATTR_R_PATTERN.find(attrs)?.groupValues?.get(1) ?: continue + val type = ATTR_T_PATTERN.find(attrs)?.groupValues?.get(1).orEmpty() + val value = if (type == "s") strings.getOrNull(raw.toInt()).orEmpty() else raw + out[ref] = value + } + return out + } + + private fun validateHeader(header: Map, fileName: String) { + fun check(col: String, expected: String) { + val actual = header[col].orEmpty() + require(actual.startsWith(expected)) { + "eToro $fileName: unexpected header in column $col, got '$actual', expected to start with '$expected'" + } + } + check("A", EXPECTED_DATE_HEADER) + check("B", EXPECTED_NAME_HEADER) + check("C", EXPECTED_NET_HEADER) + check("I", EXPECTED_WHT_HEADER) + } + + private fun decodeXmlEntities(s: String): String = s + .replace(" ", "\n") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") +} diff --git a/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt new file mode 100644 index 0000000..abcdc4b --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt @@ -0,0 +1,112 @@ +package cz.solutions.cockroach + +import org.apache.pdfbox.Loader +import org.apache.pdfbox.text.PDFTextStripper +import org.joda.time.LocalDate +import java.io.File +import java.util.logging.Logger + +/** + * Parses a VÚB CZK account statement PDF and extracts every "Credit interest" + * (or Slovak "Úroky pripísané") posting as an [InterestRecord]. + * + * VÚB exports the table as plain text columns. After [PDFTextStripper] each + * posting is rendered as four consecutive lines: + * + * [] + * // e.g. "0201IG0013829" + * // Czech format: "125,23" or "1.234,56" + * Credit interest // anchor label + * + * The label is used as the anchor; the three preceding lines provide the + * value date (the last DD/MM token, which matches the reference prefix), the + * IG-style reference, and the amount in CZK. Non-standard rows whose + * reference does not match the IG pattern are skipped with a warning. + */ +object VubInterestPdfParser { + + private val LOGGER = Logger.getLogger(VubInterestPdfParser::class.java.name) + private const val BROKER_NAME = "VÚB" + private val LABELS = listOf("Credit interest", "Úroky pripísané") + private val DATE_TOKEN = Regex("""\b(\d{2})/(\d{2})\b""") + private val IG_REFERENCE = Regex("""^\d{4}IG\d+$""") + private val IBAN_IN_FILENAME = Regex("""(SK\d{22})""") + + fun parse(file: File, year: Int): List { + Loader.loadPDF(file).use { doc -> + val stripper = PDFTextStripper() + stripper.startPage = 1 + stripper.endPage = doc.numberOfPages + val text = stripper.getText(doc) + require(text.contains("Currency: CZK", ignoreCase = true)) { + "VÚB statement ${file.name} does not declare 'Currency: CZK' – non-CZK accounts are not supported." + } + val product = extractProduct(file, text) + return parseText(text, year, product, file.name) + } + } + + private fun parseText(text: String, year: Int, product: String, fileName: String): List { + val lines = text.lines() + val records = mutableListOf() + var skipped = 0 + + for (i in lines.indices) { + val label = lines[i].trim() + if (LABELS.none { it.equals(label, ignoreCase = true) }) continue + if (i < 3) { + skipped++; continue + } + val dateLine = lines[i - 3].trim() + val refLine = lines[i - 2].trim() + val amountLine = lines[i - 1].trim() + + if (!IG_REFERENCE.matches(refLine)) { + LOGGER.warning("VÚB $fileName: skipping non-standard 'Credit interest' near line ${i + 1} (reference='$refLine')") + skipped++; continue + } + val date = lastDateToken(dateLine, year) + if (date == null) { + LOGGER.warning("VÚB $fileName: cannot parse date from '$dateLine' near line ${i + 1}; skipping") + skipped++; continue + } + val amount = parseAmount(amountLine) + if (amount == null) { + LOGGER.warning("VÚB $fileName: cannot parse amount from '$amountLine' near line ${i + 1}; skipping") + skipped++; continue + } + if (amount <= 0.0) { + skipped++; continue + } + records.add(InterestRecord(date, amount, Currency.CZK, product = product, broker = BROKER_NAME)) + } + LOGGER.info("VÚB: parsed ${records.size} interest record(s) from $fileName (skipped=$skipped)") + return records + } + + private fun lastDateToken(line: String, year: Int): LocalDate? { + val matches = DATE_TOKEN.findAll(line).toList() + if (matches.isEmpty()) return null + val last = matches.last() + val day = last.groupValues[1].toInt() + val month = last.groupValues[2].toInt() + return try { + LocalDate(year, month, day) + } catch (_: Exception) { + null + } + } + + private fun parseAmount(line: String): Double? { + // Czech format: "1.234,56" – '.' is thousands separator, ',' is decimal. + val cleaned = line.replace("\u00a0", "").replace(" ", "") + .replace(".", "").replace(",", ".") + return cleaned.toDoubleOrNull() + } + + private fun extractProduct(file: File, text: String): String { + IBAN_IN_FILENAME.find(file.name)?.let { return it.groupValues[1] } + IBAN_IN_FILENAME.find(text)?.let { return it.groupValues[1] } + return file.nameWithoutExtension + } +} diff --git a/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt new file mode 100644 index 0000000..1ef513d --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt @@ -0,0 +1,185 @@ +package cz.solutions.cockroach + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.joda.time.LocalDate +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class EtoroXlsxParserTest { + + @Test + fun parsesDividendAndWithholdingTaxFromSyntheticWorkbook(@TempDir tempDir: File) { + val file = File(tempDir, "etoro.xlsx") + writeEtoroLikeXlsx(file, listOf( + EtoroRow(date = "01/02/2025", instrument = "AAPL", net = 0.85, wht = 0.15), + EtoroRow(date = "15/06/2025", instrument = "VOD.L", net = 4.50, wht = 0.50), + )) + + val result = EtoroXlsxParser.parse(file) + + assertThat(result.dividendRecords).containsExactly( + DividendRecord(LocalDate(2025, 2, 1), 1.00, Currency.USD, symbol = "AAPL", broker = "eToro"), + DividendRecord(LocalDate(2025, 6, 15), 5.00, Currency.USD, symbol = "VOD.L", broker = "eToro"), + ) + assertThat(result.taxRecords).containsExactly( + TaxRecord(LocalDate(2025, 2, 1), -0.15, Currency.USD), + TaxRecord(LocalDate(2025, 6, 15), -0.50, Currency.USD), + ) + } + + @Test + fun grossDividendIsNetPlusWithholdingTax(@TempDir tempDir: File) { + val file = File(tempDir, "etoro.xlsx") + writeEtoroLikeXlsx(file, listOf(EtoroRow("10/03/2025", "MSFT", net = 0.40, wht = 0.10))) + + val result = EtoroXlsxParser.parse(file) + + assertThat(result.dividendRecords).hasSize(1) + assertThat(result.dividendRecords[0].amount).isEqualTo(0.50, within(1e-9)) + assertThat(result.taxRecords[0].amount).isEqualTo(-0.10, within(1e-9)) + } + + @Test + fun rowsWithoutWithholdingTaxProduceNoTaxRecord(@TempDir tempDir: File) { + val file = File(tempDir, "etoro.xlsx") + writeEtoroLikeXlsx(file, listOf(EtoroRow("05/07/2025", "TSLA", net = 1.00, wht = 0.0))) + + val result = EtoroXlsxParser.parse(file) + + assertThat(result.dividendRecords).hasSize(1) + assertThat(result.dividendRecords[0].amount).isEqualTo(1.00, within(1e-9)) + assertThat(result.taxRecords).isEmpty() + } + + @Test + fun nonPositiveGrossDividendIsSkipped(@TempDir tempDir: File) { + val file = File(tempDir, "etoro.xlsx") + writeEtoroLikeXlsx(file, listOf( + EtoroRow("01/01/2025", "ZZZ", net = 0.0, wht = 0.0), + EtoroRow("02/01/2025", "AAPL", net = 0.85, wht = 0.15), + )) + + val result = EtoroXlsxParser.parse(file) + + assertThat(result.dividendRecords).containsExactly( + DividendRecord(LocalDate(2025, 1, 2), 1.00, Currency.USD, symbol = "AAPL", broker = "eToro") + ) + assertThat(result.taxRecords).containsExactly( + TaxRecord(LocalDate(2025, 1, 2), -0.15, Currency.USD) + ) + } + + @Test + fun headerMismatchFailsFast(@TempDir tempDir: File) { + val file = File(tempDir, "etoro.xlsx") + // Write a workbook whose column A header is wrong; parser must reject it. + writeEtoroLikeXlsx(file, rows = emptyList(), headers = listOf( + "Wrong", "Instrument Name", "Net Dividend Received", + "x", "x", "x", "x", "x", "Withholding Tax Amount" + )) + + val ex = org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException::class.java) { + EtoroXlsxParser.parse(file) + } + assertThat(ex.message).contains("unexpected header in column A") + } + + // ---- synthetic XLSX builder ---------------------------------------------------- + + private data class EtoroRow(val date: String, val instrument: String, val net: Double, val wht: Double) + + private val DEFAULT_HEADERS = listOf( + "Date of Payment", + "Instrument Name", + "Net Dividend Received", + "x", "x", "x", "x", "x", + "Withholding Tax Amount", + ) + + private fun writeEtoroLikeXlsx(file: File, rows: List, headers: List = DEFAULT_HEADERS) { + // Build sharedStrings: headers first, then per-row instruments (deduplicated). + val instruments = rows.map { it.instrument }.distinct() + val strings = headers + instruments + val instrumentIndex = instruments.withIndex().associate { (i, v) -> v to headers.size + i } + + val sharedStringsXml = buildString { + append("""""") + append("""""") + strings.forEach { append("").append(escapeXml(it)).append("") } + append("") + } + + val sheetXml = buildString { + append("""""") + append("""""") + // header row uses shared-string indexes 0..headers.size-1; eToro emits attributes as + // r="..." s="0" t="s" – we reproduce that exact ordering to lock down the regex fix. + append("""""") + headers.forEachIndexed { i, _ -> + val ref = "${('A' + i)}1" + append("""$i""") + } + append("") + rows.forEachIndexed { idx, row -> + val rNum = idx + 2 + append("""""") + append("""${headers.size + headers.size /* placeholder */}""".replace("${headers.size + headers.size}", "${stringIndexFor(row.date, strings)}")) + // simpler: rewrite cleanly below + setLength(length - "".length - "${stringIndexFor(row.date, strings)}".length - "".length) + append("""""") + // Date is also a shared string in eToro statements + val dateIdx = ensureString(row.date, strings, sharedStringsAddenda) + append("""$dateIdx""") + append("""${instrumentIndex[row.instrument]}""") + append("""${row.net}""") + append("""${row.wht}""") + append("") + } + append("") + } + + // Recompose the final shared strings including any dates appended on the fly. + val finalStrings = strings + sharedStringsAddenda + val finalSharedStringsXml = buildString { + append("""""") + append("""""") + finalStrings.forEach { append("").append(escapeXml(it)).append("") } + append("") + } + + ZipOutputStream(file.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("xl/sharedStrings.xml")) + zip.write(finalSharedStringsXml.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + zip.putNextEntry(ZipEntry("xl/worksheets/sheet4.xml")) + zip.write(sheetXml.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + sharedStringsAddenda.clear() + } + + private val sharedStringsAddenda = mutableListOf() + + private fun stringIndexFor(value: String, strings: List): Int { + val i = strings.indexOf(value) + return if (i >= 0) i else strings.size + sharedStringsAddenda.indexOf(value).also { if (it < 0) sharedStringsAddenda.add(value) } + } + + private fun ensureString(value: String, strings: List, addenda: MutableList): Int { + val i = strings.indexOf(value) + if (i >= 0) return i + val j = addenda.indexOf(value) + if (j >= 0) return strings.size + j + addenda.add(value) + return strings.size + addenda.size - 1 + } + + private fun escapeXml(s: String) = s + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} From b2a15bb13eb853ddb2e318af93f6c61b3a949256 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 17:58:00 +0200 Subject: [PATCH 06/31] improve guide for interest --- .../cz/solutions/cockroach/InterestRecord.kt | 4 + .../cz/solutions/cockroach/InterestReport.kt | 37 +++++-- .../cockroach/InterestReportPdfGenerator.kt | 32 +++--- .../cockroach/InterestReportPreparation.kt | 67 ++++++++++--- .../kotlin/cz/solutions/cockroach/Report.kt | 6 +- .../cz/solutions/cockroach/RevolutParser.kt | 25 ++++- .../cockroach/VubInterestPdfParser.kt | 3 +- .../cz/solutions/cockroach/guide.html.hbs | 17 +++- .../cockroach/EtoroXlsxParserTest.kt | 97 ++++++++----------- .../InterestReportPreparationTest.kt | 42 +++++++- 10 files changed, 230 insertions(+), 100 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt index cd13860..add987c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt @@ -8,4 +8,8 @@ data class InterestRecord( val currency: Currency = Currency.USD, val product: String = "", val broker: String = "", + /** Withholding tax already deducted at source, stored as a non-negative value in the source currency. */ + val tax: Double = 0.0, + /** ISO 3166-1 alpha-2 country code identifying the source of the interest income (e.g. "IE", "SK", "CZ"). */ + val country: String = "", ) diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt index 4a75191..2749ec3 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt @@ -5,34 +5,59 @@ data class PrintableInterest( val broker: String, val date: String, val brutto: String, + val tax: String, val exchange: String, - val bruttoCrown: String + val bruttoCrown: String, + val taxCrown: String, ) data class PrintableCzkInterest( val product: String, val broker: String, val date: String, - val brutto: String + val brutto: String, + val tax: String, ) data class CurrencyInterestSection( + val country: String, val currency: Currency, val printableInterestList: List, val totalBrutto: Double, - val totalBruttoCrown: Double + val totalTax: Double, + val totalBruttoCrown: Double, + val totalTaxCrown: Double, ) data class CzkInterestSection( + val country: String, val printableInterestList: List, - val totalBruttoCrown: Double + val totalBruttoCrown: Double, + val totalTaxCrown: Double, ) +/** Aggregate of all interest income (CZK and non-CZK) per source country, in CZK. */ +data class CountryInterestTotal( + val country: String, + val totalBruttoCrown: Double, + val totalTaxCrown: Double, +) { + val totalBruttoCrownFormatted: String get() = FormatingHelper.formatRounded(totalBruttoCrown) + val totalTaxCrownFormatted: String get() = FormatingHelper.formatRounded(totalTaxCrown) +} + data class InterestReport( val sections: List, - val czkSection: CzkInterestSection? + val czkSections: List, + val countryTotals: List, ) { val totalNonCzkBruttoCrown: Double get() = sections.sumOf { it.totalBruttoCrown } - val totalCzkBruttoCrown: Double get() = czkSection?.totalBruttoCrown ?: 0.0 + val totalCzkBruttoCrown: Double get() = czkSections.sumOf { it.totalBruttoCrown } val totalBruttoCrown: Double get() = totalNonCzkBruttoCrown + totalCzkBruttoCrown + + /** Single CZK domestic section ("CZ" country), if any – preserved for legacy assertions. */ + val czkSection: CzkInterestSection? get() = czkSections.firstOrNull { it.country == "CZ" } + + /** Country totals limited to foreign sources (i.e. anything that should land on Příloha č. 3). */ + val foreignCountryTotals: List get() = countryTotals.filter { it.country != "CZ" } } diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt index 72358c7..05e155f 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt @@ -12,7 +12,9 @@ object InterestReportPdfGenerator { for (section in report.sections) { pdfs.add(generateCurrencySectionPdf(section)) } - report.czkSection?.let { pdfs.add(generateCzkSectionPdf(it)) } + for (section in report.czkSections) { + pdfs.add(generateCzkSectionPdf(section)) + } if (pdfs.isEmpty()) { return PdfReportGenerator.generate(PdfReportDefinition( @@ -29,13 +31,14 @@ object InterestReportPdfGenerator { private fun generateCurrencySectionPdf(section: CurrencyInterestSection): ByteArray { val cur = section.currency.name + val country = section.country val columns = listOf( PdfColumn("Produkt", 2f), PdfColumn("Obchodník", 1.5f), - PdfColumn("Datum", 0.9f), PdfColumn("Brutto ($cur)", 0.9f), - PdfColumn("Kurz (Kč/$cur)", 1f), PdfColumn("Brutto (Kč)", 0.9f) + PdfColumn("Datum", 0.9f), PdfColumn("Brutto ($cur)", 0.9f), PdfColumn("Sražená daň ($cur)", 1.1f), + PdfColumn("Kurz (Kč/$cur)", 1f), PdfColumn("Brutto (Kč)", 0.9f), PdfColumn("Sražená daň (Kč)", 1.1f) ) val rows = section.printableInterestList.map { i -> - listOf(i.product, i.broker, i.date, i.brutto, i.exchange, i.bruttoCrown) + listOf(i.product, i.broker, i.date, i.brutto, i.tax, i.exchange, i.bruttoCrown, i.taxCrown) } val fmt = FormatingHelper::formatDouble val summaryRow = listOf( @@ -43,33 +46,38 @@ object InterestReportPdfGenerator { SummaryCell.empty(), // Obchodník SummaryCell.bold("Celkem"), SummaryCell.bold(fmt(section.totalBrutto)), + SummaryCell.bold(fmt(section.totalTax)), SummaryCell.empty(), - SummaryCell.bold(fmt(section.totalBruttoCrown)) + SummaryCell.bold(fmt(section.totalBruttoCrown)), + SummaryCell.bold(fmt(section.totalTaxCrown)), ) return PdfReportGenerator.generate(PdfReportDefinition( - title = "Úroky (§8) – rozpis – $cur", - subtitles = listOf("Měna zdroje: $cur"), + title = "Úroky (§8) – rozpis – $country / $cur", + subtitles = listOf("Země zdroje: $country", "Měna zdroje: $cur"), columns = columns, rows = rows, summaryRow = summaryRow, landscape = false )) } private fun generateCzkSectionPdf(section: CzkInterestSection): ByteArray { + val country = section.country val columns = listOf( PdfColumn("Produkt", 2f), PdfColumn("Obchodník", 1.5f), - PdfColumn("Datum", 1f), PdfColumn("Brutto (Kč)", 1f) + PdfColumn("Datum", 1f), PdfColumn("Brutto (Kč)", 1f), PdfColumn("Sražená daň (Kč)", 1.1f) ) - val rows = section.printableInterestList.map { i -> listOf(i.product, i.broker, i.date, i.brutto) } + val rows = section.printableInterestList.map { i -> listOf(i.product, i.broker, i.date, i.brutto, i.tax) } val fmt = FormatingHelper::formatDouble val summaryRow = listOf( SummaryCell.empty(), // Produkt SummaryCell.empty(), // Obchodník SummaryCell.bold("Celkem"), - SummaryCell.bold(fmt(section.totalBruttoCrown)) + SummaryCell.bold(fmt(section.totalBruttoCrown)), + SummaryCell.bold(fmt(section.totalTaxCrown)), ) + val title = if (country == "CZ") "Úroky ze zdrojů v ČR – rozpis" else "Úroky (§8) – rozpis – $country / CZK" return PdfReportGenerator.generate(PdfReportDefinition( - title = "Úroky ze zdrojů v ČR – rozpis", - subtitles = listOf("Měna zdroje: CZK"), + title = title, + subtitles = listOf("Země zdroje: $country", "Měna zdroje: CZK"), columns = columns, rows = rows, summaryRow = summaryRow, landscape = false )) diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt index 957d308..5b15fd2 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt @@ -13,22 +13,51 @@ object InterestReportPreparation { ): InterestReport { val interestsInInterval = interestRecordList.filter { interval.contains(it.date) } - val interestsByCurrency = interestsInInterval.groupBy { it.currency } + .map { withResolvedCountry(it) } + val grouped = interestsInInterval.groupBy { it.country to it.currency } - val nonCzkCurrencies = interestsByCurrency.keys - .filter { it != Currency.CZK } - .sortedBy { it.name } + val sections = grouped.entries + .filter { (key, _) -> key.second != Currency.CZK } + .sortedWith(compareBy({ it.key.first }, { it.key.second.name })) + .map { (key, records) -> buildCurrencySection(key.first, key.second, records, exchangeRateProvider) } - val sections = nonCzkCurrencies.map { currency -> - buildCurrencySection(currency, interestsByCurrency.getValue(currency), exchangeRateProvider) - } + val czkSections = grouped.entries + .filter { (key, _) -> key.second == Currency.CZK } + .sortedBy { it.key.first } + .map { (key, records) -> buildCzkSection(key.first, records) } + + val countryTotals = buildCountryTotals(sections, czkSections) + + return InterestReport(sections, czkSections, countryTotals) + } - val czkSection = interestsByCurrency[Currency.CZK]?.let { buildCzkSection(it) } + /** Backfills a default country code so old test fixtures and unattributed records still group sanely. */ + private fun withResolvedCountry(record: InterestRecord): InterestRecord { + if (record.country.isNotBlank()) return record + val fallback = if (record.currency == Currency.CZK) "CZ" else "??" + return record.copy(country = fallback) + } - return InterestReport(sections, czkSection) + private fun buildCountryTotals( + sections: List, + czkSections: List, + ): List { + val accumulator = linkedMapOf>() + for (section in sections) { + val (b, t) = accumulator[section.country] ?: (0.0 to 0.0) + accumulator[section.country] = (b + section.totalBruttoCrown) to (t + section.totalTaxCrown) + } + for (section in czkSections) { + val (b, t) = accumulator[section.country] ?: (0.0 to 0.0) + accumulator[section.country] = (b + section.totalBruttoCrown) to (t + section.totalTaxCrown) + } + return accumulator.entries + .sortedBy { it.key } + .map { CountryInterestTotal(it.key, it.value.first, it.value.second) } } private fun buildCurrencySection( + country: String, currency: Currency, interestRecords: List, exchangeRateProvider: ExchangeRateProvider @@ -36,12 +65,17 @@ object InterestReportPreparation { val sortedInterests = interestRecords.sortedBy { it.date } val printable = mutableListOf() var totalBrutto = 0.0 + var totalTax = 0.0 var totalBruttoCrown = 0.0 + var totalTaxCrown = 0.0 for (interestRecord in sortedInterests) { val exchange = exchangeRateProvider.rateAt(interestRecord.date, currency) + val taxCrown = interestRecord.tax * exchange totalBrutto += interestRecord.amount + totalTax += interestRecord.tax totalBruttoCrown += interestRecord.amount * exchange + totalTaxCrown += taxCrown printable.add( PrintableInterest( @@ -49,31 +83,36 @@ object InterestReportPreparation { interestRecord.broker, DATE_FORMATTER.print(interestRecord.date), FormatingHelper.formatDouble(interestRecord.amount), + FormatingHelper.formatDouble(interestRecord.tax), FormatingHelper.formatExchangeRate(exchange), - FormatingHelper.formatDouble(exchange * interestRecord.amount) + FormatingHelper.formatDouble(exchange * interestRecord.amount), + FormatingHelper.formatDouble(taxCrown), ) ) } - return CurrencyInterestSection(currency, printable, totalBrutto, totalBruttoCrown) + return CurrencyInterestSection(country, currency, printable, totalBrutto, totalTax, totalBruttoCrown, totalTaxCrown) } - private fun buildCzkSection(interestRecords: List): CzkInterestSection { + private fun buildCzkSection(country: String, interestRecords: List): CzkInterestSection { val sortedInterests = interestRecords.sortedBy { it.date } val printable = mutableListOf() var totalBruttoCrown = 0.0 + var totalTaxCrown = 0.0 for (interestRecord in sortedInterests) { totalBruttoCrown += interestRecord.amount + totalTaxCrown += interestRecord.tax printable.add( PrintableCzkInterest( interestRecord.product, interestRecord.broker, DATE_FORMATTER.print(interestRecord.date), - FormatingHelper.formatDouble(interestRecord.amount) + FormatingHelper.formatDouble(interestRecord.amount), + FormatingHelper.formatDouble(interestRecord.tax), ) ) } - return CzkInterestSection(printable, totalBruttoCrown) + return CzkInterestSection(country, printable, totalBruttoCrown, totalTaxCrown) } } diff --git a/src/main/kotlin/cz/solutions/cockroach/Report.kt b/src/main/kotlin/cz/solutions/cockroach/Report.kt index a61f1f1..1fe5d81 100644 --- a/src/main/kotlin/cz/solutions/cockroach/Report.kt +++ b/src/main/kotlin/cz/solutions/cockroach/Report.kt @@ -56,7 +56,11 @@ class Report( "dividendPayedTaxCroneValue" to FormatingHelper.formatRounded(-dividendReport.totalNonCzkTaxCrown) ) val interestVars = mapOf( - "interestCroneValue" to FormatingHelper.formatRounded(interestReport.totalBruttoCrown) + "interestCroneValue" to FormatingHelper.formatRounded(interestReport.totalBruttoCrown), + "interestForeignCroneValue" to FormatingHelper.formatRounded(interestReport.foreignCountryTotals.sumOf { it.totalBruttoCrown }), + "interestForeignTaxCroneValue" to FormatingHelper.formatRounded(interestReport.foreignCountryTotals.sumOf { it.totalTaxCrown }), + "interestCountryTotals" to interestReport.foreignCountryTotals, + "interestAllCountryTotals" to interestReport.countryTotals, ) val variables = rsuAndEsppVars + salesVars + dividentVars + interestVars diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt index f231c4f..e787b67 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -31,11 +31,23 @@ object RevolutParser { private const val BROKER_NAME = "Revolut" + /** + * Country of origin for Revolut Savings interest. Flexible Account interest is paid by Irish-domiciled + * UCITS money-market funds (ISINs `IE000H9J0QX4`, `IE000AZVL3K0`, ...), so the source country reported + * on Příloha č. 3 is Ireland regardless of the cash currency (USD/EUR Class shares). + */ + private const val SAVINGS_COUNTRY = "IE" + private val SAVINGS_DATE_FORMATTER = DateTimeFormat.forPattern("MMM d, yyyy, h:mm:ss a").withLocale(Locale.US) // ISIN: 2-letter country code + 9 alphanumeric chars + 1 check digit (12 chars total). private val ISIN_PATTERN = Regex("\\b([A-Z]{2}[A-Z0-9]{9}[0-9])\\b") + // Safety net for parseSavings: any unhandled description containing one of these tokens + // would indicate Revolut started withholding tax on Flexible Accounts (e.g. fund domicile change + // or regulatory shift). Fail loudly so we never silently under-report §8 income. + private val SAVINGS_TAX_PATTERN = Regex("(?i)\\b(WHT|withholding|tax\\s+(?:withheld|deducted|paid|charged))\\b") + fun parseStocks(file: File, whtRate: Double = DEFAULT_WHT_RATE): RevolutStocksParseResult { return file.reader(StandardCharsets.UTF_8).use { parseStocks(it, whtRate) } } @@ -116,7 +128,7 @@ object RevolutParser { description.startsWith("Interest PAID") -> { if (value > 0.0) { val product = ISIN_PATTERN.find(description)?.value ?: description - interestRecords.add(InterestRecord(date, value, currency, product = product, broker = BROKER_NAME)) + interestRecords.add(InterestRecord(date, value, currency, product = product, broker = BROKER_NAME, country = SAVINGS_COUNTRY)) } } description.startsWith("Service Fee Charged") -> { @@ -124,7 +136,16 @@ object RevolutParser { feeCount++ feeCurrency = currency } - else -> { /* Interest Reinvested, BUY, SELL: ignored */ } + description.startsWith("Interest Reinvested") -> { /* purely informational; cash already counted via Interest PAID */ } + description.startsWith("BUY") || description.startsWith("SELL") -> { /* fund-unit movements */ } + else -> { + check(!SAVINGS_TAX_PATTERN.containsMatchIn(description)) { + "Revolut Savings: encountered tax-related row '$description' at $date. " + + "Parser assumes Flexible Account interest is gross (Irish UCITS, no WHT). " + + "Investigate the statement manually before re-running." + } + LOGGER.warning("Revolut Savings: unrecognised row '$description' at $date; ignored.") + } } } } diff --git a/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt index abcdc4b..4f6331c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt @@ -27,6 +27,7 @@ object VubInterestPdfParser { private val LOGGER = Logger.getLogger(VubInterestPdfParser::class.java.name) private const val BROKER_NAME = "VÚB" + private const val COUNTRY = "SK" private val LABELS = listOf("Credit interest", "Úroky pripísané") private val DATE_TOKEN = Regex("""\b(\d{2})/(\d{2})\b""") private val IG_REFERENCE = Regex("""^\d{4}IG\d+$""") @@ -78,7 +79,7 @@ object VubInterestPdfParser { if (amount <= 0.0) { skipped++; continue } - records.add(InterestRecord(date, amount, Currency.CZK, product = product, broker = BROKER_NAME)) + records.add(InterestRecord(date, amount, Currency.CZK, product = product, broker = BROKER_NAME, country = COUNTRY)) } LOGGER.info("VÚB: parsed ${records.size} interest record(s) from $fileName (skipped=$skipped)") return records diff --git a/src/main/resources/cz/solutions/cockroach/guide.html.hbs b/src/main/resources/cz/solutions/cockroach/guide.html.hbs index 3018ca7..640636f 100644 --- a/src/main/resources/cz/solutions/cockroach/guide.html.hbs +++ b/src/main/resources/cz/solutions/cockroach/guide.html.hbs @@ -246,15 +246,28 @@

Interest

-

Úrokové příjmy ze zahraničí (např. Revolut Flexible Cash Funds) bez sražené zahraniční daně. Patří do § 8 a uvádějí se na Příloze č. 2 DAP, kde se přičítají k dílčímu základu daně z kapitálového majetku.

+

Úrokové příjmy ze zahraničí (např. Revolut Flexible Cash Funds, VÚB SK). Pro účely zápočtu zahraniční daně se příjmy a sražená daň člení dle státu zdroje na Příloze č. 3.

+

Úhrn úrokových příjmů {{interestForeignCroneValue}} CZK, daň zaplacená v zahraničí {{interestForeignTaxCroneValue}} CZK.

+ +

PŘÍLOHA 3 – Výpočet daně z příjmů ze zahraničí

+

Pro každý stát zdroje vyplňte samostatný oddíl Přílohy č. 3. Hodnoty níže jsou již přepočtené na Kč jednotnými kurzy ČNB.

+ +{{#each interestCountryTotals}} +

Stát zdroje: {{country}}

+ - + + +
Řádek Popis Vyplní v celých Kč
Úhrn úrokových příjmů (§ 8) ze zahraničí v Kč{{interestCroneValue}}
321Příjmy ze zdrojů v zahraničí (§ 8 – úroky){{totalBruttoCrownFormatted}}
322Výdaje k příjmům ze zdrojů v zahraničí0
323Daň zaplacená v zahraničí podle § 38f odst. 1 zákona{{totalTaxCrownFormatted}}
+{{else}} +

Žádné zahraniční úrokové příjmy v daném období.

+{{/each}} \ No newline at end of file diff --git a/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt index 1ef513d..8226d62 100644 --- a/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt @@ -3,6 +3,7 @@ package cz.solutions.cockroach import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.within import org.joda.time.LocalDate +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File @@ -11,6 +12,14 @@ import java.util.zip.ZipOutputStream class EtoroXlsxParserTest { + private val DEFAULT_HEADERS = listOf( + "Date of Payment", // A + "Instrument Name", // B + "Net Dividend Received", // C + "x", "x", "x", "x", "x", // D..H (unused by the parser) + "Withholding Tax Amount", // I + ) + @Test fun parsesDividendAndWithholdingTaxFromSyntheticWorkbook(@TempDir tempDir: File) { val file = File(tempDir, "etoro.xlsx") @@ -76,65 +85,54 @@ class EtoroXlsxParserTest { @Test fun headerMismatchFailsFast(@TempDir tempDir: File) { val file = File(tempDir, "etoro.xlsx") - // Write a workbook whose column A header is wrong; parser must reject it. writeEtoroLikeXlsx(file, rows = emptyList(), headers = listOf( "Wrong", "Instrument Name", "Net Dividend Received", - "x", "x", "x", "x", "x", "Withholding Tax Amount" + "x", "x", "x", "x", "x", "Withholding Tax Amount", )) - val ex = org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException::class.java) { + val ex = assertThrows(IllegalArgumentException::class.java) { EtoroXlsxParser.parse(file) } assertThat(ex.message).contains("unexpected header in column A") } - // ---- synthetic XLSX builder ---------------------------------------------------- + // --- synthetic XLSX helpers -------------------------------------------------- private data class EtoroRow(val date: String, val instrument: String, val net: Double, val wht: Double) - private val DEFAULT_HEADERS = listOf( - "Date of Payment", - "Instrument Name", - "Net Dividend Received", - "x", "x", "x", "x", "x", - "Withholding Tax Amount", - ) - - private fun writeEtoroLikeXlsx(file: File, rows: List, headers: List = DEFAULT_HEADERS) { - // Build sharedStrings: headers first, then per-row instruments (deduplicated). - val instruments = rows.map { it.instrument }.distinct() - val strings = headers + instruments - val instrumentIndex = instruments.withIndex().associate { (i, v) -> v to headers.size + i } + private fun writeEtoroLikeXlsx( + file: File, + rows: List, + headers: List = DEFAULT_HEADERS, + ) { + val pool = LinkedHashMap() + fun intern(s: String): Int = pool.getOrPut(s) { pool.size } + headers.forEach { intern(it) } + rows.forEach { intern(it.date); intern(it.instrument) } val sharedStringsXml = buildString { append("""""") - append("""""") - strings.forEach { append("").append(escapeXml(it)).append("") } + append("""""") + pool.keys.forEach { append("").append(escapeXml(it)).append("") } append("") } val sheetXml = buildString { append("""""") append("""""") - // header row uses shared-string indexes 0..headers.size-1; eToro emits attributes as - // r="..." s="0" t="s" – we reproduce that exact ordering to lock down the regex fix. + // eToro emits cell attributes as r="..." s="0" t="s" (i.e. style before type), + // which previously broke the parser regex. We reproduce that exact ordering + // here so the test locks down the regression. append("""""") - headers.forEachIndexed { i, _ -> - val ref = "${('A' + i)}1" - append("""$i""") + headers.forEachIndexed { i, h -> + append("""${pool[h]}""") } append("") rows.forEachIndexed { idx, row -> val rNum = idx + 2 append("""""") - append("""${headers.size + headers.size /* placeholder */}""".replace("${headers.size + headers.size}", "${stringIndexFor(row.date, strings)}")) - // simpler: rewrite cleanly below - setLength(length - "".length - "${stringIndexFor(row.date, strings)}".length - "".length) - append("""""") - // Date is also a shared string in eToro statements - val dateIdx = ensureString(row.date, strings, sharedStringsAddenda) - append("""$dateIdx""") - append("""${instrumentIndex[row.instrument]}""") + append("""${pool[row.date]}""") + append("""${pool[row.instrument]}""") append("""${row.net}""") append("""${row.wht}""") append("") @@ -142,40 +140,25 @@ class EtoroXlsxParserTest { append("") } - // Recompose the final shared strings including any dates appended on the fly. - val finalStrings = strings + sharedStringsAddenda - val finalSharedStringsXml = buildString { - append("""""") - append("""""") - finalStrings.forEach { append("").append(escapeXml(it)).append("") } - append("") - } - ZipOutputStream(file.outputStream()).use { zip -> zip.putNextEntry(ZipEntry("xl/sharedStrings.xml")) - zip.write(finalSharedStringsXml.toByteArray(Charsets.UTF_8)) + zip.write(sharedStringsXml.toByteArray(Charsets.UTF_8)) zip.closeEntry() zip.putNextEntry(ZipEntry("xl/worksheets/sheet4.xml")) zip.write(sheetXml.toByteArray(Charsets.UTF_8)) zip.closeEntry() } - sharedStringsAddenda.clear() } - private val sharedStringsAddenda = mutableListOf() - - private fun stringIndexFor(value: String, strings: List): Int { - val i = strings.indexOf(value) - return if (i >= 0) i else strings.size + sharedStringsAddenda.indexOf(value).also { if (it < 0) sharedStringsAddenda.add(value) } - } - - private fun ensureString(value: String, strings: List, addenda: MutableList): Int { - val i = strings.indexOf(value) - if (i >= 0) return i - val j = addenda.indexOf(value) - if (j >= 0) return strings.size + j - addenda.add(value) - return strings.size + addenda.size - 1 + private fun col(index: Int): String { + var n = index + 1 + val sb = StringBuilder() + while (n > 0) { + val r = (n - 1) % 26 + sb.append(('A' + r)) + n = (n - 1) / 26 + } + return sb.reverse().toString() } private fun escapeXml(s: String) = s diff --git a/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt index 7046dd4..e615387 100644 --- a/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt @@ -18,9 +18,9 @@ class InterestReportPreparationTest { @Test fun aggregatesInterestPerCurrencyAndAppliesExchangeRate() { val records = listOf( - InterestRecord(LocalDate(2025, 3, 15), 10.0, Currency.USD), - InterestRecord(LocalDate(2025, 6, 20), 5.0, Currency.USD), - InterestRecord(LocalDate(2025, 9, 10), 4.0, Currency.EUR), + InterestRecord(LocalDate(2025, 3, 15), 10.0, Currency.USD, country = "IE"), + InterestRecord(LocalDate(2025, 6, 20), 5.0, Currency.USD, country = "IE"), + InterestRecord(LocalDate(2025, 9, 10), 4.0, Currency.EUR, country = "IE"), ) val report = InterestReportPreparation.generateInterestReport( @@ -28,19 +28,25 @@ class InterestReportPreparationTest { ) assertThat(report.sections).hasSize(2) - assertThat(report.czkSection).isNull() + assertThat(report.czkSections).isEmpty() val usd = report.sections.first { it.currency == Currency.USD } + assertThat(usd.country).isEqualTo("IE") assertThat(usd.totalBrutto).isCloseTo(15.0, offset(0.0001)) assertThat(usd.totalBruttoCrown).isCloseTo(300.0, offset(0.0001)) assertThat(usd.printableInterestList).hasSize(2) val eur = report.sections.first { it.currency == Currency.EUR } + assertThat(eur.country).isEqualTo("IE") assertThat(eur.totalBrutto).isCloseTo(4.0, offset(0.0001)) assertThat(eur.totalBruttoCrown).isCloseTo(100.0, offset(0.0001)) assertThat(report.totalNonCzkBruttoCrown).isCloseTo(400.0, offset(0.0001)) assertThat(report.totalBruttoCrown).isCloseTo(400.0, offset(0.0001)) + + assertThat(report.countryTotals).hasSize(1) + assertThat(report.countryTotals[0].country).isEqualTo("IE") + assertThat(report.countryTotals[0].totalBruttoCrown).isCloseTo(400.0, offset(0.0001)) } @Test @@ -65,7 +71,7 @@ class InterestReportPreparationTest { fun keepsCzkInterestInDedicatedSection() { val records = listOf( InterestRecord(LocalDate(2025, 5, 5), 1234.50, Currency.CZK), - InterestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD), + InterestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD, country = "IE"), ) val report = InterestReportPreparation.generateInterestReport( @@ -75,17 +81,43 @@ class InterestReportPreparationTest { assertThat(report.sections).hasSize(1) assertThat(report.sections[0].currency).isEqualTo(Currency.USD) assertThat(report.czkSection).isNotNull + assertThat(report.czkSection!!.country).isEqualTo("CZ") assertThat(report.czkSection!!.totalBruttoCrown).isCloseTo(1234.50, offset(0.0001)) assertThat(report.totalBruttoCrown).isCloseTo(1234.50 + 2000.0, offset(0.0001)) } + @Test + fun groupsForeignCzkInterestUnderItsSourceCountry() { + // VÚB pays CZK from a Slovak source – it must NOT land in the domestic CZ bucket. + val records = listOf( + InterestRecord(LocalDate(2025, 5, 5), 1000.0, Currency.CZK, country = "SK", broker = "VÚB"), + InterestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD, country = "IE", broker = "Revolut"), + ) + + val report = InterestReportPreparation.generateInterestReport( + records, DateInterval.year(2025), fixedRate + ) + + assertThat(report.czkSections).hasSize(1) + assertThat(report.czkSections[0].country).isEqualTo("SK") + assertThat(report.czkSection).isNull() // no domestic CZ section + + assertThat(report.foreignCountryTotals.map { it.country }).containsExactly("IE", "SK") + assertThat(report.foreignCountryTotals.first { it.country == "SK" }.totalBruttoCrown) + .isCloseTo(1000.0, offset(0.0001)) + assertThat(report.foreignCountryTotals.first { it.country == "IE" }.totalBruttoCrown) + .isCloseTo(2000.0, offset(0.0001)) + } + @Test fun emptyInputProducesEmptyReport() { val report = InterestReportPreparation.generateInterestReport( emptyList(), DateInterval.year(2025), fixedRate ) assertThat(report.sections).isEmpty() + assertThat(report.czkSections).isEmpty() assertThat(report.czkSection).isNull() + assertThat(report.countryTotals).isEmpty() assertThat(report.totalBruttoCrown).isEqualTo(0.0) } } From 1f7407585e8b596edb738a572b13f5f099909cf6 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 18:15:15 +0200 Subject: [PATCH 07/31] Adapt upstream tax-reuse test to per-currency dividend report API The upstream fix in a42f282 added a test that asserted on the single-currency DividendReport shape (printableDividendList, totalTaxDollar, totalTaxCrown directly on the report). After rebasing the multi-currency refactor on top, those fields live on DividendReport.sections[Currency.USD] instead, and the ExchangeRateProvider SAM now takes (LocalDate, Currency). The fix itself (mutable per-date tax candidate list, remove on match) is retained in DividentReportPreparation.kt across both buildCurrencySection and buildCzkSection. --- .../DividentReportPreparationTest.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt index df2a3ce..b06a6b1 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test class DividentReportPreparationTest { - private val fixedRate = ExchangeRateProvider { 25.0 } + private val fixedRate = ExchangeRateProvider { _, _ -> 25.0 } private val year2025 = DateInterval.year(2025) @Test @@ -28,14 +28,16 @@ class DividentReportPreparationTest { dividends, taxes, emptyList(), year2025, fixedRate ) + val usd = report.sections.single { it.currency == Currency.USD } + // Both dividends should be matched - assertThat(report.printableDividendList).hasSize(2) + assertThat(usd.printableDividendList).hasSize(2) // The total tax should include BOTH tax records, not -8.86 twice - assertThat(report.totalTaxDollar).isCloseTo(-436.90, Offset.offset(0.01)) + assertThat(usd.totalTax).isCloseTo(-436.90, Offset.offset(0.01)) // Each tax should be used exactly once - assertThat(report.totalTaxCrown).isCloseTo(-436.90 * 25.0, Offset.offset(0.1)) + assertThat(usd.totalTaxCrown).isCloseTo(-436.90 * 25.0, Offset.offset(0.1)) } @Test @@ -47,8 +49,9 @@ class DividentReportPreparationTest { dividends, taxes, emptyList(), year2025, fixedRate ) - assertThat(report.printableDividendList).hasSize(1) - assertThat(report.totalTaxDollar).isCloseTo(-150.0, Offset.offset(0.01)) + val usd = report.sections.single { it.currency == Currency.USD } + assertThat(usd.printableDividendList).hasSize(1) + assertThat(usd.totalTax).isCloseTo(-150.0, Offset.offset(0.01)) } @Test @@ -66,7 +69,8 @@ class DividentReportPreparationTest { dividends, taxes, emptyList(), year2025, fixedRate ) - assertThat(report.printableDividendList).hasSize(2) - assertThat(report.totalTaxDollar).isCloseTo(-330.0, Offset.offset(0.01)) + val usd = report.sections.single { it.currency == Currency.USD } + assertThat(usd.printableDividendList).hasSize(2) + assertThat(usd.totalTax).isCloseTo(-330.0, Offset.offset(0.01)) } } From 439e268ba1a0762127196246fb32f9b464273497 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 18:50:39 +0200 Subject: [PATCH 08/31] don't cache current year --- .../solutions/cockroach/CnbYearRatesSource.kt | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt index 8ca7a37..5ef0b2b 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt @@ -4,7 +4,6 @@ import org.joda.time.LocalDate import java.io.File import java.net.URI import java.nio.charset.StandardCharsets -import java.util.concurrent.TimeUnit import java.util.logging.Logger /** @@ -41,10 +40,14 @@ class ClasspathCnbYearRatesSource : CnbYearRatesSource { } /** - * Downloads CNB year.txt from cnb.cz on first use and caches each year on - * disk under [cacheDir]. Past years are cached forever (CNB never amends - * fixings retroactively); the current year is refreshed when the cached - * copy is older than [currentYearMaxAgeMs]. + * Downloads CNB year.txt from cnb.cz on first use and caches completed + * past years on disk under [cacheDir]. CNB never amends fixings + * retroactively, but the last fixing of a year (and any late corrections) + * may take a few business days to be published. A year N is therefore + * only treated as complete – and eligible for permanent caching – once + * [today] is at least [safeDaysAfterYearEnd] days into year N+1. The + * current year (and any future year) is always downloaded fresh and + * never written to disk, since its fixing series is still growing. * * When the downloaded content contains more than one header line (e.g. the * 2022 HRK transition), it is split into multiple chunks so downstream @@ -53,33 +56,43 @@ class ClasspathCnbYearRatesSource : CnbYearRatesSource { class HttpCnbYearRatesSource( private val cacheDir: File, private val baseUrl: String = DEFAULT_BASE_URL, - private val currentYearMaxAgeMs: Long = TimeUnit.HOURS.toMillis(24), + private val safeDaysAfterYearEnd: Int = 7, private val today: () -> LocalDate = { LocalDate.now() } ) : CnbYearRatesSource { override fun loadYear(year: Int): List { - val raw = loadRawWithCache(year) + val raw = loadRaw(year) return splitByHeader(raw) } - private fun loadRawWithCache(year: Int): String { + private fun loadRaw(year: Int): String { + if (!isYearComplete(year)) { + return downloadFresh(year) + } if (!cacheDir.exists()) { require(cacheDir.mkdirs() || cacheDir.exists()) { "could not create cache directory ${cacheDir.absolutePath}" } } val cacheFile = File(cacheDir, "rates_$year.txt") - val currentYear = today().year - val stale = !cacheFile.exists() || - (year >= currentYear && - System.currentTimeMillis() - cacheFile.lastModified() > currentYearMaxAgeMs) - if (stale) { - download(year, cacheFile) + if (!cacheFile.exists()) { + downloadToFile(year, cacheFile) } return cacheFile.readText(StandardCharsets.UTF_8) } - private fun download(year: Int, target: File) { + private fun isYearComplete(year: Int): Boolean { + val safeAfter = LocalDate(year + 1, 1, 1).plusDays(safeDaysAfterYearEnd) + return !today().isBefore(safeAfter) + } + + private fun downloadFresh(year: Int): String { + val url = URI("$baseUrl?year=$year").toURL() + LOGGER.info("downloading CNB rates for $year from $url (incomplete year, not cached)") + return url.openStream().use { it.reader(StandardCharsets.UTF_8).readText() } + } + + private fun downloadToFile(year: Int, target: File) { val url = URI("$baseUrl?year=$year").toURL() LOGGER.info("downloading CNB rates for $year from $url") val tmp = File(target.parentFile, "${target.name}.tmp") From b13236929562e5454d58383d2c67c60f9f034162 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 19:05:07 +0200 Subject: [PATCH 09/31] Fail loudly when a dividend has no matching tax record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously dividends without a same-date tax record were silently dropped from the §7 report, leading to under-reporting (e.g. Revolut Stocks parsed with whtRate=0.0 emits no TaxRecord, so the dividend would never have appeared on the form). Now both buildCurrencySection and buildCzkSection throw an IllegalStateException with a descriptive message identifying the offending dividend (date, broker, symbol, amount, currency) and suggesting two concrete remedies: emit an explicit zero TaxRecord for genuine 0% WHT cases, or check that the broker statement's tax row matches the dividend date. Test added: dividendWithoutMatchingTaxRecordFailsWithDescriptiveMessage --- .../cz/solutions/cockroach/DividendRecord.kt | 4 +- .../cockroach/DividentReportPreparation.kt | 71 ++++++++++--------- .../cz/solutions/cockroach/InterestRecord.kt | 6 +- .../DividentReportPreparationTest.kt | 23 ++++++ 4 files changed, 67 insertions(+), 37 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt index f3e0282..3570735 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt @@ -6,6 +6,6 @@ data class DividendRecord( val date: LocalDate, val amount: Double, val currency: Currency = Currency.USD, - val symbol: String = "", - val broker: String = "", + val symbol: String, + val broker: String, ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt index b9bbf61..bad58e5 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt @@ -67,26 +67,25 @@ object DividentReportPreparation { val exchange = exchangeRateProvider.rateAt(dividendRecord.date, currency) val taxCandidates = taxesByDate[dividendRecord.date] val taxRecord = taxCandidates?.minByOrNull { abs(abs(it.amount) - abs(dividendRecord.amount) * 0.15) } //if there were more taxes on the same day, we take the one closest to 15% of dividend amount, because that's the most likely correct one - if (taxRecord != null) taxCandidates.remove(taxRecord) - if (taxRecord != null) { - totalBrutto += dividendRecord.amount - totalTax += taxRecord.amount - totalBruttoCrown += dividendRecord.amount * exchange - totalTaxCrown += taxRecord.amount * exchange - - printable.add( - PrintableDividend( - dividendRecord.symbol, - dividendRecord.broker, - DATE_FORMATTER.print(dividendRecord.date), - FormatingHelper.formatDouble(dividendRecord.amount), - FormatingHelper.formatExchangeRate(exchange), - FormatingHelper.formatDouble(taxRecord.amount), - FormatingHelper.formatDouble(exchange * dividendRecord.amount), - FormatingHelper.formatDouble(exchange * taxRecord.amount) - ) + ?: error(missingTaxMessage(dividendRecord, currency)) + taxCandidates.remove(taxRecord) + totalBrutto += dividendRecord.amount + totalTax += taxRecord.amount + totalBruttoCrown += dividendRecord.amount * exchange + totalTaxCrown += taxRecord.amount * exchange + + printable.add( + PrintableDividend( + dividendRecord.symbol, + dividendRecord.broker, + DATE_FORMATTER.print(dividendRecord.date), + FormatingHelper.formatDouble(dividendRecord.amount), + FormatingHelper.formatExchangeRate(exchange), + FormatingHelper.formatDouble(taxRecord.amount), + FormatingHelper.formatDouble(exchange * dividendRecord.amount), + FormatingHelper.formatDouble(exchange * taxRecord.amount) ) - } + ) } val totalTaxReversal = reversalRecords.sumOf { it.amount } @@ -109,23 +108,31 @@ object DividentReportPreparation { for (dividendRecord in sortedDividends) { val taxCandidates = taxesByDate[dividendRecord.date] val taxRecord = taxCandidates?.minByOrNull { abs(abs(it.amount) - abs(dividendRecord.amount) * 0.15) } - if (taxRecord != null) taxCandidates.remove(taxRecord) - if (taxRecord != null) { - totalBruttoCrown += dividendRecord.amount - totalTaxCrown += taxRecord.amount - printable.add( - PrintableCzkDividend( - dividendRecord.symbol, - dividendRecord.broker, - DATE_FORMATTER.print(dividendRecord.date), - FormatingHelper.formatDouble(dividendRecord.amount), - FormatingHelper.formatDouble(taxRecord.amount) - ) + ?: error(missingTaxMessage(dividendRecord, Currency.CZK)) + taxCandidates.remove(taxRecord) + totalBruttoCrown += dividendRecord.amount + totalTaxCrown += taxRecord.amount + printable.add( + PrintableCzkDividend( + dividendRecord.symbol, + dividendRecord.broker, + DATE_FORMATTER.print(dividendRecord.date), + FormatingHelper.formatDouble(dividendRecord.amount), + FormatingHelper.formatDouble(taxRecord.amount) ) - } + ) } val totalTaxReversalCrown = reversalRecords.sumOf { it.amount } return CzkDividendSection(printable, totalBruttoCrown, totalTaxCrown, totalTaxReversalCrown) } + + private fun missingTaxMessage(dividend: DividendRecord, currency: Currency): String { + val symbol = dividend.symbol + val broker = dividend.broker + return "No matching tax record found for dividend on ${DATE_FORMATTER.print(dividend.date)} " + + "(broker=$broker, symbol=$symbol, amount=${FormatingHelper.formatDouble(dividend.amount)} ${currency.name}). " + + "If withholding tax is genuinely 0%, add an explicit TaxRecord with amount=0.0 on the same date in the parser; " + + "otherwise verify that the broker statement contains the corresponding tax row and that its date matches the dividend date." + } } \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt index add987c..ffbdf34 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt @@ -6,10 +6,10 @@ data class InterestRecord( val date: LocalDate, val amount: Double, val currency: Currency = Currency.USD, - val product: String = "", - val broker: String = "", + val product: String, + val broker: String, /** Withholding tax already deducted at source, stored as a non-negative value in the source currency. */ val tax: Double = 0.0, /** ISO 3166-1 alpha-2 country code identifying the source of the interest income (e.g. "IE", "SK", "CZ"). */ - val country: String = "", + val country: String, ) diff --git a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt index b06a6b1..319fa53 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt @@ -1,6 +1,7 @@ package cz.solutions.cockroach import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.assertj.core.data.Offset import org.joda.time.LocalDate import org.junit.jupiter.api.Test @@ -54,6 +55,28 @@ class DividentReportPreparationTest { assertThat(usd.totalTax).isCloseTo(-150.0, Offset.offset(0.01)) } + @Test + fun dividendWithoutMatchingTaxRecordFailsWithDescriptiveMessage() { + // A dividend without a tax row almost always means a parser bug or a missed tax row in the + // broker statement. Force the user to investigate rather than silently under-reporting. + val dividends = listOf( + DividendRecord(LocalDate(2025, 6, 15), 500.0, symbol = "ACME", broker = "Schwab") + ) + + assertThatThrownBy { + DividentReportPreparation.generateDividendReport( + dividends, emptyList(), emptyList(), year2025, fixedRate + ) + } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("No matching tax record") + .hasMessageContaining("15.06.2025") + .hasMessageContaining("broker=Schwab") + .hasMessageContaining("symbol=ACME") + .hasMessageContaining("USD") + .hasMessageContaining("amount=0.0 on the same date") + } + @Test fun dividendsOnDifferentDatesMatchCorrectTax() { val dividends = listOf( From de38007b1e71c3a4e59215f8528fc5bb805f160d Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 19:06:35 +0200 Subject: [PATCH 10/31] Bump CNB cache safety threshold from 7 to 30 days CNB occasionally publishes the very last fixings of December a few business days late, and any retroactive corrections appear in early January. Caching a year file on Jan 8 risked freezing in a still-growing year. Treat year N as complete only once today() is at least 30 days into year N+1 (i.e. ~Jan 31). That comfortably clears any late-December corrections while still being well before the late-March / early-April Czech tax-filing window, so previous-year rates are downloaded fresh at most a handful of times per filing season. --- src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt index 5ef0b2b..8d9376a 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt @@ -56,7 +56,7 @@ class ClasspathCnbYearRatesSource : CnbYearRatesSource { class HttpCnbYearRatesSource( private val cacheDir: File, private val baseUrl: String = DEFAULT_BASE_URL, - private val safeDaysAfterYearEnd: Int = 7, + private val safeDaysAfterYearEnd: Int = 30, private val today: () -> LocalDate = { LocalDate.now() } ) : CnbYearRatesSource { From 23daf94a545c7044850cd31577934f9bc1092dc4 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 19:07:23 +0200 Subject: [PATCH 11/31] Fix diff calculation in new-legislative recommendation The 'fixed' branch printed a wrong diff value: the last subtrahend was profit2024WhenUsedDynamicRate instead of profitWhenUsedFixedRate, so the displayed difference between the two strategies was nonsense (it contained the dynamic 2024 figure twice and never subtracted the fixed post-2024 figure). The else branch already had the correct expression (fixedTotal - dynamicTotal); this commit makes the if branch symmetrical (dynamicTotal - fixedTotal) so the printed diff is consistent with the comparison that selected the branch. --- src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index e6b55b3..2f30bf5 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -245,7 +245,7 @@ object CockroachMain { "Use fixed Dollar conversion rate because " + "${FormatingHelper.formatDouble(profit2024WhenUsedFixedRate)}+${FormatingHelper.formatDouble(profitWhenUsedFixedRate)}<=" + "${FormatingHelper.formatDouble(profit2024WhenUsedDynamicRate)}+${FormatingHelper.formatDouble(profitWhenUsedDynamicRate)} " + - "(diff=${FormatingHelper.formatDouble(profit2024WhenUsedDynamicRate+profitWhenUsedDynamicRate - profit2024WhenUsedFixedRate-profit2024WhenUsedDynamicRate)})" + "(diff=${FormatingHelper.formatDouble(profit2024WhenUsedDynamicRate+profitWhenUsedDynamicRate - profit2024WhenUsedFixedRate-profitWhenUsedFixedRate)})" } else { "Use dynamic Dollar conversion rate, because " + "${FormatingHelper.formatDouble(profit2024WhenUsedDynamicRate)}+${FormatingHelper.formatDouble(profitWhenUsedDynamicRate)}" + From 977ed2599c487d79b7f6702b27cef1b56eff7e5d Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 19:15:57 +0200 Subject: [PATCH 12/31] Centralise hardcoded 2024 legislative-transition year The literal '2024' was duplicated across ReportGenerator (twice, in the DateInterval.year() arguments for the rsuReport2024/esppReport2024 fields) and CockroachMain (twice in PDF output filenames and twice in the recommendation banner strings). The number is not arbitrary: it is the Czech tax-law cutover year for unsold RSU/ESPP equity, after which the old vs. new methodology comparison no longer needs to be emitted. Introduce ReportGenerator.LEGISLATIVE_TRANSITION_YEAR and route every caller through it. Variable and report-field names that mention 2024 are intentionally left alone -- they refer to the cutover year specifically, so a literal name carries useful information. Behaviour is unchanged. --- .../kotlin/cz/solutions/cockroach/CockroachMain.kt | 10 ++++++---- .../kotlin/cz/solutions/cockroach/ReportGenerator.kt | 11 +++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index 2f30bf5..aa03270 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -99,8 +99,9 @@ object CockroachMain { File(outputDir, "${dollarConversionSchema}_sales_$year.pdf").writeBytes(data.getSalesPdf()) File(outputDir, "${dollarConversionSchema}_guide_$year.html").writeText(data.getGuide(), StandardCharsets.UTF_8) - File(outputDir, "${dollarConversionSchema}_rsu_2024.pdf").writeBytes(data.getRsu2024Pdf()) - File(outputDir, "${dollarConversionSchema}_espp_2024.pdf").writeBytes(data.getEspp2024Pdf()) + val transitionYear = ReportGenerator.LEGISLATIVE_TRANSITION_YEAR + File(outputDir, "${dollarConversionSchema}_rsu_$transitionYear.pdf").writeBytes(data.getRsu2024Pdf()) + File(outputDir, "${dollarConversionSchema}_espp_$transitionYear.pdf").writeBytes(data.getEspp2024Pdf()) } @@ -233,8 +234,9 @@ object CockroachMain { "Use dynamic Dollar conversion rate, because ${FormatingHelper.formatDouble(profitWhenUsedDynamicRate)}<${FormatingHelper.formatDouble(profitWhenUsedFixedRate)} (diff=${FormatingHelper.formatDouble(profitWhenUsedFixedRate - profitWhenUsedDynamicRate)})" } + val transitionYear = ReportGenerator.LEGISLATIVE_TRANSITION_YEAR println("######################################################") - println("# Recommendation (If old legislative was used for 2024): ") + println("# Recommendation (If old legislative was used for $transitionYear): ") println("# $recommendationOldLegislativeUsedIn2024") println("######################################################") println() @@ -254,7 +256,7 @@ object CockroachMain { } println("######################################################") - println("# Recommendation (If new legislative was used in 2024) ") + println("# Recommendation (If new legislative was used in $transitionYear) ") println("# $recomendationNewLegislativeUsed2024") println("######################################################") diff --git a/src/main/kotlin/cz/solutions/cockroach/ReportGenerator.kt b/src/main/kotlin/cz/solutions/cockroach/ReportGenerator.kt index 1f30470..fa3877c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ReportGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ReportGenerator.kt @@ -2,6 +2,13 @@ package cz.solutions.cockroach object ReportGenerator { + /** + * Year of the Czech RSU/ESPP taxation legislative cutover. The "..._2024" reports compare the + * old vs. new methodology for unsold equity granted before this year and are emitted alongside + * every per-year report. Centralised so the value appears in exactly one place. + */ + const val LEGISLATIVE_TRANSITION_YEAR = 2024 + fun generateForYear(parsedExport: ParsedExport, year: Int, exchangeRateProvider: ExchangeRateProvider): Report { val interval = DateInterval.year(year) @@ -38,7 +45,7 @@ object ReportGenerator { rsuReport2024 = RsuReportPreparation.generateRsuReport( parsedExport.rsuRecords, parsedExport.saleRecords, - DateInterval.year(2024), + DateInterval.year(LEGISLATIVE_TRANSITION_YEAR), exchangeRateProvider, {quantity, soldQuantity -> quantity-soldQuantity} ), @@ -46,7 +53,7 @@ object ReportGenerator { esppReport2024 = EsppReportPreparation.generateEsppReport( parsedExport.esppRecords, parsedExport.saleRecords, - DateInterval.year(2024), + DateInterval.year(LEGISLATIVE_TRANSITION_YEAR), exchangeRateProvider, {quantity, soldQuantity -> quantity-soldQuantity} ), From c16e1624819af0eefdc9257890a93c67f33b5cdc Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 19:20:16 +0200 Subject: [PATCH 13/31] Repair tests broken by removal of defaults on InterestRecord/DividendRecord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production data classes deliberately require broker / symbol / product / country to be supplied at every call site, so that real parsers cannot silently lose attribution information. Several test call sites were still using the pre-change zero-arg form and stopped compiling. Introduce TestRecords.kt with two tiny factory functions (dividendRecord, interestRecord) that supply harmless defaults for the metadata fields tests do not care about (symbol=TEST, broker=TestBroker, product=TestProduct, country=IE). Migrate the affected test files to use them; tests that intentionally exercise specific metadata (e.g. country=SK for VÚB) keep their explicit arguments. No production code is changed. --- .../DividentReportPreparationTest.kt | 10 ++--- .../cockroach/ETradeGainLossParserTest.kt | 2 +- .../InterestReportPreparationTest.kt | 22 +++++----- .../cz/solutions/cockroach/TestRecords.kt | 41 +++++++++++++++++++ 4 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 src/test/kotlin/cz/solutions/cockroach/TestRecords.kt diff --git a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt index 319fa53..0ddc0aa 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt @@ -17,8 +17,8 @@ class DividentReportPreparationTest { // E-Trade: small dividend with 15% withholding // Both on the same date - simulates the real bug val dividends = listOf( - DividendRecord(LocalDate(2025, 10, 22), 1426.80), // Schwab - DividendRecord(LocalDate(2025, 10, 22), 59.04) // E-Trade + dividendRecord(LocalDate(2025, 10, 22), 1426.80), // Schwab + dividendRecord(LocalDate(2025, 10, 22), 59.04) // E-Trade ) val taxes = listOf( TaxRecord(LocalDate(2025, 10, 22), -428.04), // Schwab (30% of 1426.80) @@ -43,7 +43,7 @@ class DividentReportPreparationTest { @Test fun singleDividendWithSingleTaxOnSameDate() { - val dividends = listOf(DividendRecord(LocalDate(2025, 1, 22), 1000.0)) + val dividends = listOf(dividendRecord(LocalDate(2025, 1, 22), 1000.0)) val taxes = listOf(TaxRecord(LocalDate(2025, 1, 22), -150.0)) val report = DividentReportPreparation.generateDividendReport( @@ -80,8 +80,8 @@ class DividentReportPreparationTest { @Test fun dividendsOnDifferentDatesMatchCorrectTax() { val dividends = listOf( - DividendRecord(LocalDate(2025, 1, 22), 1000.0), - DividendRecord(LocalDate(2025, 4, 23), 1200.0) + dividendRecord(LocalDate(2025, 1, 22), 1000.0), + dividendRecord(LocalDate(2025, 4, 23), 1200.0) ) val taxes = listOf( TaxRecord(LocalDate(2025, 1, 22), -150.0), diff --git a/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt index 5a9a9c8..10df2be 100644 --- a/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt @@ -70,7 +70,7 @@ class ETradeGainLossParserTest { val schwab = ParsedExport( rsuRecords = listOf(RsuRecord(LocalDate(2023, 12, 10), 2, 48.38, LocalDate(2023, 12, 10), "1461994")), esppRecords = emptyList(), - dividendRecords = listOf(DividendRecord(LocalDate(2023, 10, 25), 84.38)), + dividendRecords = listOf(dividendRecord(LocalDate(2023, 10, 25), 84.38)), taxRecords = emptyList(), taxReversalRecords = emptyList(), saleRecords = listOf(SaleRecord(LocalDate(2023, 9, 27), "RS", 30.0, 47.62, 43.91, 43.91, LocalDate(2022, 11, 10), "1538646")), diff --git a/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt index e615387..3a118df 100644 --- a/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt @@ -18,9 +18,9 @@ class InterestReportPreparationTest { @Test fun aggregatesInterestPerCurrencyAndAppliesExchangeRate() { val records = listOf( - InterestRecord(LocalDate(2025, 3, 15), 10.0, Currency.USD, country = "IE"), - InterestRecord(LocalDate(2025, 6, 20), 5.0, Currency.USD, country = "IE"), - InterestRecord(LocalDate(2025, 9, 10), 4.0, Currency.EUR, country = "IE"), + interestRecord(LocalDate(2025, 3, 15), 10.0, Currency.USD, country = "IE"), + interestRecord(LocalDate(2025, 6, 20), 5.0, Currency.USD, country = "IE"), + interestRecord(LocalDate(2025, 9, 10), 4.0, Currency.EUR, country = "IE"), ) val report = InterestReportPreparation.generateInterestReport( @@ -52,10 +52,10 @@ class InterestReportPreparationTest { @Test fun filtersRecordsOutsideOfInterval() { val records = listOf( - InterestRecord(LocalDate(2024, 12, 31), 100.0, Currency.USD), - InterestRecord(LocalDate(2025, 1, 1), 1.0, Currency.USD), - InterestRecord(LocalDate(2025, 12, 31), 2.0, Currency.USD), - InterestRecord(LocalDate(2026, 1, 1), 100.0, Currency.USD), + interestRecord(LocalDate(2024, 12, 31), 100.0, Currency.USD), + interestRecord(LocalDate(2025, 1, 1), 1.0, Currency.USD), + interestRecord(LocalDate(2025, 12, 31), 2.0, Currency.USD), + interestRecord(LocalDate(2026, 1, 1), 100.0, Currency.USD), ) val report = InterestReportPreparation.generateInterestReport( @@ -70,8 +70,8 @@ class InterestReportPreparationTest { @Test fun keepsCzkInterestInDedicatedSection() { val records = listOf( - InterestRecord(LocalDate(2025, 5, 5), 1234.50, Currency.CZK), - InterestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD, country = "IE"), + interestRecord(LocalDate(2025, 5, 5), 1234.50, Currency.CZK, country = "CZ"), + interestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD, country = "IE"), ) val report = InterestReportPreparation.generateInterestReport( @@ -90,8 +90,8 @@ class InterestReportPreparationTest { fun groupsForeignCzkInterestUnderItsSourceCountry() { // VÚB pays CZK from a Slovak source – it must NOT land in the domestic CZ bucket. val records = listOf( - InterestRecord(LocalDate(2025, 5, 5), 1000.0, Currency.CZK, country = "SK", broker = "VÚB"), - InterestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD, country = "IE", broker = "Revolut"), + interestRecord(LocalDate(2025, 5, 5), 1000.0, Currency.CZK, country = "SK", broker = "VÚB"), + interestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD, country = "IE", broker = "Revolut"), ) val report = InterestReportPreparation.generateInterestReport( diff --git a/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt new file mode 100644 index 0000000..d434c24 --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt @@ -0,0 +1,41 @@ +package cz.solutions.cockroach + +import org.joda.time.LocalDate + +/** + * Test-only constructors that supply reasonable defaults for fields most tests do not care about. + * Production data classes intentionally do not declare defaults for these fields so that real + * callers cannot silently drop broker/symbol/product/country attribution. + */ + +fun dividendRecord( + date: LocalDate, + amount: Double, + currency: Currency = Currency.USD, + symbol: String = "TEST", + broker: String = "TestBroker", +): DividendRecord = DividendRecord( + date = date, + amount = amount, + currency = currency, + symbol = symbol, + broker = broker, +) + +fun interestRecord( + date: LocalDate, + amount: Double, + currency: Currency = Currency.USD, + product: String = "TestProduct", + broker: String = "TestBroker", + tax: Double = 0.0, + country: String = "IE", +): InterestRecord = InterestRecord( + date = date, + amount = amount, + currency = currency, + product = product, + broker = broker, + tax = tax, + country = country, +) From 3a3ff1a6f53f33cf4a75486144f03c07219259df Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 19:23:33 +0200 Subject: [PATCH 14/31] Expand CLI usage text and fail gracefully when no broker source is given The previous usage message only listed the positional Schwab/E-Trade form and gave a one-line nod to the YAML form, despite the YAML config being the only way to feed Degiro, Revolut, eToro and VUB into the report. Users that ran 'cockroach' with no arguments could not discover those options, and a YAML file (or empty Schwab export) with no broker sources crashed with an IllegalArgumentException stack trace from require() in CockroachMain.report(). - Rewrite the help text to lead with the YAML form, list every supported broker key (schwab, etrade, etradeBenefitHistory, degiro, revolut.stocks, revolut.savings, revolut.whtRate, etoro, vub) and keep the legacy positional form clearly marked as Schwab/E-Trade only. - Wrap main() so any IllegalArgumentException (including the existing 'No input sources provided. Specify at least one of: schwab, etrade, degiro, revolut, etoro, vub.' guard) is reported as a one-line stderr message and the process exits with code 1, instead of dumping a stack trace. - Switch System.exit calls to kotlin.system.exitProcess for consistency. No production behaviour changes for valid inputs; tests still pass. --- .../cz/solutions/cockroach/CockroachMain.kt | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index aa03270..8bcc867 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -3,8 +3,18 @@ package cz.solutions.cockroach import java.io.File import java.nio.charset.StandardCharsets import java.util.logging.Logger +import kotlin.system.exitProcess fun main(args: Array) { + try { + runCockroach(args) + } catch (e: IllegalArgumentException) { + System.err.println("Error: ${e.message}") + exitProcess(1) + } +} + +private fun runCockroach(args: Array) { if (args.size == 1 && (args[0].endsWith(".yaml") || args[0].endsWith(".yml"))) { val config = CockroachConfig.load(File(args[0])) CockroachMain.report( @@ -23,24 +33,43 @@ fun main(args: Array) { return } if (args.size < 3) { - System.err.println("Usage: cockroach [etrade-dir]") - System.err.println(" cockroach ") - System.err.println() - System.err.println(" schwab-json-export Path to the Schwab JSON export file") - System.err.println(" year Tax year (e.g. 2025)") - System.err.println(" output-dir Directory for generated reports") - System.err.println(" etrade-dir Optional E-Trade data directory with subdirs:") - System.err.println(" rsu/ - RSU release confirmation PDFs") - System.err.println(" espp/ - ESPP purchase confirmation PDFs") - System.err.println(" dividends/ - single dividends XLSX file") - System.err.println(" sales/ - single Gain & Loss CSV file") - System.err.println(" config.yaml YAML config file with year, outputDir, schwab, etrade, etradeBenefitHistory, degiro") - System.exit(1) + printUsage() + exitProcess(1) } val eTradeDir = if (args.size > 3) File(args[3]) else null CockroachMain.report(File(args[0]), args[1].toInt(), File(args[2]), eTradeDir) } +private fun printUsage() { + System.err.println("Usage: cockroach (recommended)") + System.err.println(" cockroach [etrade-dir] (Schwab/E-Trade only)") + System.err.println() + System.err.println("Positional CLI form (limited):") + System.err.println(" schwab-json-export Path to the Schwab JSON export file") + System.err.println(" year Tax year (e.g. 2025)") + System.err.println(" output-dir Directory for generated reports") + System.err.println(" etrade-dir Optional E-Trade data directory with subdirs:") + System.err.println(" rsu/ - RSU release confirmation PDFs") + System.err.println(" espp/ - ESPP purchase confirmation PDFs") + System.err.println(" dividends/ - single dividends XLSX file") + System.err.println(" sales/ - single Gain & Loss CSV/XLSX file") + System.err.println() + System.err.println("YAML config form (recommended; supports every broker):") + System.err.println(" year: Tax year, e.g. 2025") + System.err.println(" outputDir: Directory for generated reports") + System.err.println(" schwab: Path to a Schwab JSON export (optional)") + System.err.println(" etrade: Path to an E-Trade data directory (optional)") + System.err.println(" etradeBenefitHistory: Path to an E-Trade benefit-history XLSX (optional)") + System.err.println(" degiro: List of Degiro account-statement CSV paths (optional)") + System.err.println(" revolut.stocks: List of Revolut stock statement paths (optional)") + System.err.println(" revolut.savings: List of Revolut savings statement paths (optional)") + System.err.println(" revolut.whtRate: Withholding-tax rate applied to Revolut dividends (optional)") + System.err.println(" etoro: List of eToro XLSX export paths (optional)") + System.err.println(" vub: List of VÚB interest-confirmation PDF paths (optional)") + System.err.println() + System.err.println("At least one broker source must be configured.") +} + object CockroachMain { private val LOGGER = Logger.getLogger(CockroachMain::class.java.name) From 5c47dc40e6a354cb35a4ea37eb85b17a232ba692 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 19:26:27 +0200 Subject: [PATCH 15/31] cli update --- src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index 8bcc867..adab910 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -44,7 +44,7 @@ private fun printUsage() { System.err.println("Usage: cockroach (recommended)") System.err.println(" cockroach [etrade-dir] (Schwab/E-Trade only)") System.err.println() - System.err.println("Positional CLI form (limited):") + System.err.println("Positional CLI form (limited to E-Trade and Schwab):") System.err.println(" schwab-json-export Path to the Schwab JSON export file") System.err.println(" year Tax year (e.g. 2025)") System.err.println(" output-dir Directory for generated reports") @@ -54,7 +54,7 @@ private fun printUsage() { System.err.println(" dividends/ - single dividends XLSX file") System.err.println(" sales/ - single Gain & Loss CSV/XLSX file") System.err.println() - System.err.println("YAML config form (recommended; supports every broker):") + System.err.println("YAML config form (supports every broker):") System.err.println(" year: Tax year, e.g. 2025") System.err.println(" outputDir: Directory for generated reports") System.err.println(" schwab: Path to a Schwab JSON export (optional)") @@ -71,7 +71,6 @@ private fun printUsage() { } object CockroachMain { - private val LOGGER = Logger.getLogger(CockroachMain::class.java.name) fun report( schwabExportFile: File?, From 9441d70349bbdf3119b8978a7a6cd91372e6af71 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 20:39:50 +0200 Subject: [PATCH 16/31] This change extracts that per-broker logic into one BrokerSource per broker (Schwab, E-Trade, Degiro, Revolut, eToro, VUB), each owning its own input shape (single file / list / directory / WHT rate / year). The small file-loading helper that several of them need moves to a top-level loadText() in BrokerSource.kt; the matching helper inside CockroachMain is removed. --- .../cz/solutions/cockroach/BrokerSource.kt | 16 ++ .../cz/solutions/cockroach/CockroachMain.kt | 195 +++--------------- .../solutions/cockroach/DegiroBrokerSource.kt | 23 +++ .../solutions/cockroach/ETradeBrokerSource.kt | 64 ++++++ .../solutions/cockroach/EtoroBrokerSource.kt | 23 +++ .../cz/solutions/cockroach/FileHelper.kt | 13 ++ .../cockroach/RevolutBrokerSource.kt | 46 +++++ .../solutions/cockroach/SchwabBrokerSource.kt | 12 ++ .../cz/solutions/cockroach/VubBrokerSource.kt | 27 +++ 9 files changed, 254 insertions(+), 165 deletions(-) create mode 100644 src/main/kotlin/cz/solutions/cockroach/BrokerSource.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/DegiroBrokerSource.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/EtoroBrokerSource.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/FileHelper.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/RevolutBrokerSource.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/SchwabBrokerSource.kt create mode 100644 src/main/kotlin/cz/solutions/cockroach/VubBrokerSource.kt diff --git a/src/main/kotlin/cz/solutions/cockroach/BrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/BrokerSource.kt new file mode 100644 index 0000000..63edadd --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/BrokerSource.kt @@ -0,0 +1,16 @@ +package cz.solutions.cockroach + +/** + * Single per-broker entry point used by [CockroachMain.report]. + * + * Each implementation owns the entire mapping from "the configured input(s) for this broker" to a + * [ParsedExport]. To add a new broker, drop in a new [BrokerSource] implementation and instantiate + * it from [runCockroach]; nothing else in [CockroachMain] needs to change. + */ +interface BrokerSource { + /** Human-readable name used in error messages and recommendations (e.g. "Schwab", "VÚB"). */ + val name: String + + /** Parses this broker's configured inputs into a [ParsedExport]. */ + fun parse(): ParsedExport +} diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index adab910..a4bbc00 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -17,27 +17,37 @@ fun main(args: Array) { private fun runCockroach(args: Array) { if (args.size == 1 && (args[0].endsWith(".yaml") || args[0].endsWith(".yml"))) { val config = CockroachConfig.load(File(args[0])) - CockroachMain.report( - schwabExportFile = config.schwab?.let { File(it) }, - year = config.year, - outputDir = File(config.outputDir), - eTradeDir = config.etrade?.let { File(it) }, - eTradeBenefitHistoryFile = config.etradeBenefitHistory?.let { File(it) }, - degiroFiles = config.degiro.map { File(it) }, - revolutStocksFiles = config.revolut.stocks.map { File(it) }, - revolutSavingsFiles = config.revolut.savings.map { File(it) }, - revolutWhtRate = config.revolut.whtRate, - etoroFiles = config.etoro.map { File(it) }, - vubFiles = config.vub.map { File(it) }, - ) + val sources = buildList { + config.schwab?.let { add(SchwabBrokerSource(File(it))) } + if (config.etrade != null || config.etradeBenefitHistory != null) { + add(ETradeBrokerSource( + directory = config.etrade?.let { File(it) }, + benefitHistoryFile = config.etradeBenefitHistory?.let { File(it) }, + )) + } + if (config.degiro.isNotEmpty()) add(DegiroBrokerSource(config.degiro.map { File(it) })) + if (config.revolut.stocks.isNotEmpty() || config.revolut.savings.isNotEmpty()) { + add(RevolutBrokerSource( + stocksFiles = config.revolut.stocks.map { File(it) }, + savingsFiles = config.revolut.savings.map { File(it) }, + whtRate = config.revolut.whtRate, + )) + } + if (config.etoro.isNotEmpty()) add(EtoroBrokerSource(config.etoro.map { File(it) })) + if (config.vub.isNotEmpty()) add(VubBrokerSource(config.vub.map { File(it) }, year = config.year)) + } + CockroachMain.report(config.year, File(config.outputDir), sources) return } if (args.size < 3) { printUsage() exitProcess(1) } - val eTradeDir = if (args.size > 3) File(args[3]) else null - CockroachMain.report(File(args[0]), args[1].toInt(), File(args[2]), eTradeDir) + val sources = buildList { + add(SchwabBrokerSource(File(args[0]))) + if (args.size > 3) add(ETradeBrokerSource(directory = File(args[3]))) + } + CockroachMain.report(args[1].toInt(), File(args[2]), sources) } private fun printUsage() { @@ -72,35 +82,13 @@ private fun printUsage() { object CockroachMain { - fun report( - schwabExportFile: File?, - year: Int, - outputDir: File, - eTradeDir: File? = null, - eTradeBenefitHistoryFile: File? = null, - degiroFiles: List = emptyList(), - revolutStocksFiles: List = emptyList(), - revolutSavingsFiles: List = emptyList(), - revolutWhtRate: Double = RevolutParser.DEFAULT_WHT_RATE, - etoroFiles: List = emptyList(), - vubFiles: List = emptyList() - ) { - val schwabExport = schwabExportFile?.let { parseExportFile(it) } ?: ParsedExport.empty() - val eTradeExport = if (eTradeDir != null || eTradeBenefitHistoryFile != null) { - parseETradeDir(eTradeDir, eTradeBenefitHistoryFile) - } else ParsedExport.empty() - val degiroExport = degiroFiles.map { parseDegiroFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } - val revolutStocksExport = revolutStocksFiles.map { parseRevolutStocksFile(it, revolutWhtRate) } - .fold(ParsedExport.empty()) { acc, e -> acc + e } - val revolutSavingsExport = revolutSavingsFiles.map { parseRevolutSavingsFile(it) } - .fold(ParsedExport.empty()) { acc, e -> acc + e } - val etoroExport = etoroFiles.map { parseEtoroFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } - val vubExport = vubFiles.map { parseVubFile(it, year) }.fold(ParsedExport.empty()) { acc, e -> acc + e } - val parsedExport = schwabExport + eTradeExport + degiroExport + - revolutStocksExport + revolutSavingsExport + etoroExport + vubExport - require(parsedExport != ParsedExport.empty()) { + fun report(year: Int, outputDir: File, sources: List) { + require(sources.isNotEmpty()) { "No input sources provided. Specify at least one of: schwab, etrade, degiro, revolut, etoro, vub." } + val parsedExport = sources + .map { it.parse() } + .fold(ParsedExport.empty()) { acc, e -> acc + e } val dailyRateProvider = TabularExchangeRateProvider.fromSource( HttpCnbYearRatesSource(HttpCnbYearRatesSource.defaultCacheDir()), (year - 1)..year @@ -134,121 +122,6 @@ object CockroachMain { - private fun parseExportFile(schwabExportFile: File): ParsedExport { - return if (schwabExportFile.extension == "json") { - JsonExportParser().parse(load(schwabExportFile)) - } else { - throw IllegalArgumentException("only .json files are supported") - } - } - - private fun parseDegiroFile(file: File): ParsedExport { - val result = DegiroAccountStatementParser.parse(file) - return ParsedExport( - rsuRecords = emptyList(), - esppRecords = emptyList(), - dividendRecords = result.dividendRecords, - taxRecords = result.taxRecords, - taxReversalRecords = emptyList(), - saleRecords = emptyList(), - journalRecords = emptyList() - ) - } - - private fun parseRevolutStocksFile(file: File, whtRate: Double): ParsedExport { - val result = RevolutParser.parseStocks(file, whtRate) - return ParsedExport( - rsuRecords = emptyList(), - esppRecords = emptyList(), - dividendRecords = result.dividendRecords, - taxRecords = result.taxRecords, - taxReversalRecords = emptyList(), - saleRecords = emptyList(), - journalRecords = emptyList() - ) - } - - private fun parseRevolutSavingsFile(file: File): ParsedExport { - val result = RevolutParser.parseSavings(file) - return ParsedExport( - rsuRecords = emptyList(), - esppRecords = emptyList(), - dividendRecords = emptyList(), - taxRecords = emptyList(), - taxReversalRecords = emptyList(), - saleRecords = emptyList(), - journalRecords = emptyList(), - interestRecords = result.interestRecords - ) - } - - private fun parseEtoroFile(file: File): ParsedExport { - val result = EtoroXlsxParser.parse(file) - return ParsedExport( - rsuRecords = emptyList(), - esppRecords = emptyList(), - dividendRecords = result.dividendRecords, - taxRecords = result.taxRecords, - taxReversalRecords = emptyList(), - saleRecords = emptyList(), - journalRecords = emptyList() - ) - } - - private fun parseVubFile(file: File, year: Int): ParsedExport { - val interestRecords = VubInterestPdfParser.parse(file, year) - return ParsedExport( - rsuRecords = emptyList(), - esppRecords = emptyList(), - dividendRecords = emptyList(), - taxRecords = emptyList(), - taxReversalRecords = emptyList(), - saleRecords = emptyList(), - journalRecords = emptyList(), - interestRecords = interestRecords - ) - } - - private fun parseETradeDir(eTradeDir: File?, benefitHistoryFile: File? = null): ParsedExport { - val benefitHistory = benefitHistoryFile?.let { ETradeBenefitHistoryParser.parse(it) } - val rsuRecords = benefitHistory?.rsuRecords - ?: eTradeDir?.let { RsuPdfParser.parseDirectory(File(it, "rsu")) } - ?: emptyList() - val esppRecords = benefitHistory?.esppRecords - ?: eTradeDir?.let { EsppPdfParser.parseDirectory(File(it, "espp")) } - ?: emptyList() - val dividentXlsFile = eTradeDir?.let { locateSingleFile(File(it, "dividends"), "xlsx") } - val dividendXlsxResult = dividentXlsFile?.let { DividendXlsxParser.parse(it)} - val eTradeXlsFile = eTradeDir?.let { locateSingleFile(File(it, "sales"), "xlsx") } - val eTradeCsvFile = eTradeDir?.let { locateSingleFile(File(it, "sales"), "csv") } - - return ParsedExport( - rsuRecords = rsuRecords, - esppRecords = esppRecords, - saleRecords = eTradeXlsFile?.let { ETradeGainLossXlsParser.parse(it)} - ?:eTradeCsvFile?.let { ETradeGainLossParser.parse(load(it))} - ?: emptyList(), - dividendRecords = dividendXlsxResult?.dividendRecords?: emptyList(), - taxRecords = dividendXlsxResult?.taxRecords?:emptyList(), - taxReversalRecords = emptyList(), - journalRecords = emptyList() - ) - } - - private fun locateSingleFile(directory: File, extension: String): File? { - if (!directory.exists()){ - return null - } - require(directory.isDirectory) { "${directory.absolutePath} is not a directory" } - val files = directory.listFiles { file -> !file.isHidden && !file.name.startsWith("~") && !file.name.startsWith(".") && file.extension.equals(extension, ignoreCase = true) } - ?.toList() ?: emptyList() - require(files.size <= 1) { - if (files.isEmpty()) "No .$extension file found in ${directory.absolutePath}" - else "Expected max one .$extension file in ${directory.absolutePath}, but found ${files.size}: ${files.map { it.name }}" - } - return files.firstOrNull() - } - private fun recommendBetterAlternative(fixedRateReport: Report, dynamicRateReport: Report) { val profitWhenUsedFixedRate = fixedRateReport.rsuAndEsppAndSalesProfitCroneValue() val profitWhenUsedDynamicRate = dynamicRateReport.rsuAndEsppAndSalesProfitCroneValue() @@ -289,12 +162,4 @@ object CockroachMain { println("######################################################") } - - fun load(file: File): String { - return try { - file.readText(StandardCharsets.UTF_8) - } catch (e: Exception) { - throw RuntimeException("Could not load file ${file.absolutePath}", e) - } - } } \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/DegiroBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/DegiroBrokerSource.kt new file mode 100644 index 0000000..4eb1a1b --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/DegiroBrokerSource.kt @@ -0,0 +1,23 @@ +package cz.solutions.cockroach + +import java.io.File + +class DegiroBrokerSource(private val files: List) : BrokerSource { + override val name: String = "Degiro" + + override fun parse(): ParsedExport = + files.map { parseSingleFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } + + private fun parseSingleFile(file: File): ParsedExport { + val result = DegiroAccountStatementParser.parse(file) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = result.dividendRecords, + taxRecords = result.taxRecords, + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList() + ) + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt new file mode 100644 index 0000000..e723229 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt @@ -0,0 +1,64 @@ +package cz.solutions.cockroach + +import org.apache.poi.openxml4j.opc.internal.FileHelper +import java.io.File + +/** + * E-Trade is unusual: it accepts a directory laid out with `rsu/`, `espp/`, `dividends/` and + * `sales/` subdirectories, an optional benefit-history XLSX that supersedes the RSU/ESPP PDFs, + * or any combination of the two. At least one of [directory] or [benefitHistoryFile] must be set. + */ +class ETradeBrokerSource( + private val directory: File?, + private val benefitHistoryFile: File? = null, +) : BrokerSource { + override val name: String = "E-Trade" + + init { + require(directory != null || benefitHistoryFile != null) { + "E-Trade source needs either a directory or a benefit-history file" + } + } + + override fun parse(): ParsedExport { + val benefitHistory = benefitHistoryFile?.let { ETradeBenefitHistoryParser.parse(it) } + val rsuRecords = benefitHistory?.rsuRecords + ?: directory?.let { RsuPdfParser.parseDirectory(File(it, "rsu")) } + ?: emptyList() + val esppRecords = benefitHistory?.esppRecords + ?: directory?.let { EsppPdfParser.parseDirectory(File(it, "espp")) } + ?: emptyList() + val dividendXlsxFile = directory?.let { locateSingleFile(File(it, "dividends"), "xlsx") } + val dividendXlsxResult = dividendXlsxFile?.let { DividendXlsxParser.parse(it) } + val salesXlsxFile = directory?.let { locateSingleFile(File(it, "sales"), "xlsx") } + val salesCsvFile = directory?.let { locateSingleFile(File(it, "sales"), "csv") } + + return ParsedExport( + rsuRecords = rsuRecords, + esppRecords = esppRecords, + saleRecords = salesXlsxFile?.let { ETradeGainLossXlsParser.parse(it) } + ?: salesCsvFile?.let { ETradeGainLossParser.parse(loadText(it)) } + ?: emptyList(), + dividendRecords = dividendXlsxResult?.dividendRecords ?: emptyList(), + taxRecords = dividendXlsxResult?.taxRecords ?: emptyList(), + taxReversalRecords = emptyList(), + journalRecords = emptyList() + ) + } + + private fun locateSingleFile(directory: File, extension: String): File? { + if (!directory.exists()) { + return null + } + require(directory.isDirectory) { "${directory.absolutePath} is not a directory" } + val files = directory.listFiles { file -> + !file.isHidden && !file.name.startsWith("~") && !file.name.startsWith(".") && + file.extension.equals(extension, ignoreCase = true) + }?.toList() ?: emptyList() + require(files.size <= 1) { + "Expected max one .$extension file in ${directory.absolutePath}, " + + "but found ${files.size}: ${files.map { it.name }}" + } + return files.firstOrNull() + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/EtoroBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/EtoroBrokerSource.kt new file mode 100644 index 0000000..10d20b1 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/EtoroBrokerSource.kt @@ -0,0 +1,23 @@ +package cz.solutions.cockroach + +import java.io.File + +class EtoroBrokerSource(private val files: List) : BrokerSource { + override val name: String = "eToro" + + override fun parse(): ParsedExport = + files.map { parseSingleFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } + + private fun parseSingleFile(file: File): ParsedExport { + val result = EtoroXlsxParser.parse(file) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = result.dividendRecords, + taxRecords = result.taxRecords, + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList() + ) + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/FileHelper.kt b/src/main/kotlin/cz/solutions/cockroach/FileHelper.kt new file mode 100644 index 0000000..1bf4788 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/FileHelper.kt @@ -0,0 +1,13 @@ +package cz.solutions.cockroach + +import java.io.File +import java.nio.charset.StandardCharsets + + +fun loadText(file: File): String { + return try { + file.readText(StandardCharsets.UTF_8) + } catch (e: Exception) { + throw RuntimeException("Could not load file ${file.absolutePath}", e) + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutBrokerSource.kt new file mode 100644 index 0000000..b810e85 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutBrokerSource.kt @@ -0,0 +1,46 @@ +package cz.solutions.cockroach + +import java.io.File + +class RevolutBrokerSource( + private val stocksFiles: List, + private val savingsFiles: List, + private val whtRate: Double = RevolutParser.DEFAULT_WHT_RATE, +) : BrokerSource { + override val name: String = "Revolut" + + override fun parse(): ParsedExport { + val stocks = stocksFiles.map { parseStocksFile(it) } + .fold(ParsedExport.empty()) { acc, e -> acc + e } + val savings = savingsFiles.map { parseSavingsFile(it) } + .fold(ParsedExport.empty()) { acc, e -> acc + e } + return stocks + savings + } + + private fun parseStocksFile(file: File): ParsedExport { + val result = RevolutParser.parseStocks(file, whtRate) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = result.dividendRecords, + taxRecords = result.taxRecords, + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList() + ) + } + + private fun parseSavingsFile(file: File): ParsedExport { + val result = RevolutParser.parseSavings(file) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = emptyList(), + taxRecords = emptyList(), + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList(), + interestRecords = result.interestRecords + ) + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/SchwabBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/SchwabBrokerSource.kt new file mode 100644 index 0000000..def73be --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/SchwabBrokerSource.kt @@ -0,0 +1,12 @@ +package cz.solutions.cockroach + +import java.io.File + +class SchwabBrokerSource(private val jsonFile: File) : BrokerSource { + override val name: String = "Schwab" + + override fun parse(): ParsedExport { + require(jsonFile.extension == "json") { "Schwab export must be a .json file: ${jsonFile.absolutePath}" } + return JsonExportParser().parse(loadText(jsonFile)) + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/VubBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/VubBrokerSource.kt new file mode 100644 index 0000000..b8223eb --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/VubBrokerSource.kt @@ -0,0 +1,27 @@ +package cz.solutions.cockroach + +import java.io.File + +class VubBrokerSource( + private val files: List, + private val year: Int, +) : BrokerSource { + override val name: String = "VÚB" + + override fun parse(): ParsedExport = + files.map { parseSingleFile(it) }.fold(ParsedExport.empty()) { acc, e -> acc + e } + + private fun parseSingleFile(file: File): ParsedExport { + val interestRecords = VubInterestPdfParser.parse(file, year) + return ParsedExport( + rsuRecords = emptyList(), + esppRecords = emptyList(), + dividendRecords = emptyList(), + taxRecords = emptyList(), + taxReversalRecords = emptyList(), + saleRecords = emptyList(), + journalRecords = emptyList(), + interestRecords = interestRecords + ) + } +} From ea5019a322de30fa2fe16caaa336484e89c88a51 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 20:54:25 +0200 Subject: [PATCH 17/31] ignore dependency-reduced-pom.xml --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ea74b0b..d8e8e05 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /.claude/ comparison_workdir/ /input/ -/output/ \ No newline at end of file +/output/ +dependency-reduced-pom.xml \ No newline at end of file From b299841a4d68386f93d03976b2df2666fa3ef38f Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 20:58:08 +0200 Subject: [PATCH 18/31] bump kotlin --- pom.xml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pom.xml b/pom.xml index ad104bf..bcc7fc4 100644 --- a/pom.xml +++ b/pom.xml @@ -13,16 +13,10 @@ 17 1.7.1 true - 2.0.0 - 1.8.22 + 2.3.20 - - org.jetbrains.kotlin - kotlin-stdlib-jdk8 - ${kotlin.stdlib.version} - com.fasterxml.jackson.dataformat jackson-dataformat-csv From 7173b0c4ba8f204b0e77847c04f8bd2f32598208 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 21:29:51 +0200 Subject: [PATCH 19/31] use ISN to determine czech stock --- .../cockroach/DegiroAccountStatementParser.kt | 11 +++++-- .../cz/solutions/cockroach/DividendRecord.kt | 4 +++ .../solutions/cockroach/DividendXlsxParser.kt | 2 +- .../cockroach/DividentReportPreparation.kt | 33 ++++++++++++++----- .../cz/solutions/cockroach/EtoroXlsxParser.kt | 5 ++- .../solutions/cockroach/JsonExportParser.kt | 3 +- .../cz/solutions/cockroach/RevolutParser.kt | 3 +- .../DegiroAccountStatementParserTest.kt | 8 ++--- .../cockroach/DividendXlsxParserTest.kt | 4 +-- .../DividentReportPreparationTest.kt | 2 +- .../cockroach/EtoroXlsxParserTest.kt | 6 ++-- .../cockroach/JsonExportParserTest.kt | 3 +- .../cz/solutions/cockroach/TestRecords.kt | 2 ++ 13 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt index c024d36..597673a 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt @@ -30,6 +30,7 @@ object DegiroAccountStatementParser { private const val COL_VALUE_DATE = 2 private const val COL_PRODUCT = 3 + private const val COL_ISIN = 4 private const val COL_DESCRIPTION = 5 private const val COL_CURRENCY = 7 private const val COL_AMOUNT = 8 @@ -60,7 +61,7 @@ object DegiroAccountStatementParser { when (description.trim()) { DESC_DIVIDEND -> { val record = parseRecord(row) ?: continue - dividends.add(DividendRecord(record.date, record.amount, record.currency, symbol = record.product, broker = BROKER_NAME)) + dividends.add(DividendRecord(record.date, record.amount, record.currency, symbol = record.product, broker = BROKER_NAME, country = record.country)) } DESC_TAX -> { val record = parseRecord(row) ?: continue @@ -82,13 +83,14 @@ object DegiroAccountStatementParser { return DegiroParseResult(dividends, taxes) } - private data class ParsedRow(val date: LocalDate, val amount: Double, val currency: Currency, val product: String) + private data class ParsedRow(val date: LocalDate, val amount: Double, val currency: Currency, val product: String, val country: String) private fun parseRecord(row: Row): ParsedRow? { val dateStr = stringCell(row, COL_VALUE_DATE) ?: return null val currencyStr = stringCell(row, COL_CURRENCY) ?: return null val amountStr = stringCell(row, COL_AMOUNT) ?: return null val product = stringCell(row, COL_PRODUCT)?.trim().orEmpty() + val isin = stringCell(row, COL_ISIN)?.trim().orEmpty() val date = LocalDate.parse(dateStr.trim(), DATE_FORMATTER) val currency = try { @@ -98,7 +100,10 @@ object DegiroAccountStatementParser { return null } val amount = parseAmount(amountStr) ?: return null - return ParsedRow(date, amount, currency, product) + // First two letters of an ISIN are the ISO 3166-1 alpha-2 country code of the issuer. + // Fall back to "" rather than guessing — downstream code treats unknown country as foreign. + val country = if (isin.length >= 2) isin.substring(0, 2).uppercase() else "" + return ParsedRow(date, amount, currency, product, country) } private fun parseAmount(input: String): Double? { diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt index 3570735..4657f41 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt @@ -8,4 +8,8 @@ data class DividendRecord( val currency: Currency = Currency.USD, val symbol: String, val broker: String, + /** ISO 3166-1 alpha-2 country of issuer, derived from the first two letters of the ISIN. + * "CZ" routes the dividend to the Czech-source (final withholding) section; everything else + * is reported as foreign income on Příloha č. 3. */ + val country: String, ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt index 888d994..59c6c0a 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt @@ -48,7 +48,7 @@ object DividendXlsxParser { if (description.contains("WITHHOLDING", ignoreCase = true)) { taxes.add(TaxRecord(date, value)) } else { - dividends.add(DividendRecord(date, value, symbol = symbol, broker = BROKER_NAME)) + dividends.add(DividendRecord(date, value, symbol = symbol, broker = BROKER_NAME, country = "US")) } } } diff --git a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt index bad58e5..453a908 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt @@ -19,30 +19,45 @@ object DividentReportPreparation { val taxesInInterval = taxRecordList.filter { interval.contains(it.date) } val reversalsInInterval = taxReversalRecordList.filter { interval.contains(it.date) } - val dividendsByCurrency = dividendsInInterval.groupBy { it.currency } + // Czech-source vs. foreign split is driven by the issuer's country (ISIN prefix), not the + // payment currency. Czech-source dividends are subject to final withholding (§ 36 ZDP) and + // reported separately; foreign dividends go to Příloha č. 3. + val czDividends = dividendsInInterval.filter { it.country == "CZ" } + val foreignDividends = dividendsInInterval.filter { it.country != "CZ" } + + // Sanity check: a CZ-source dividend paid in a non-CZK currency would land in a per-currency + // foreign section but is conceptually Czech-source. Czech issuers virtually always pay in CZK + // — flag the unusual case rather than guess how to convert. + foreignDividends.firstOrNull { it.currency == Currency.CZK }?.let { + error("Foreign-source dividend (country=${it.country}) paid in CZK is not supported by the current report layout " + + "(broker=${it.broker}, symbol=${it.symbol}, date=${DATE_FORMATTER.print(it.date)}). " + + "If this is genuine, extend DividentReportPreparation to handle a CZK foreign-currency section.") + } + + val foreignDividendsByCurrency = foreignDividends.groupBy { it.currency } val taxesByCurrency = taxesInInterval.groupBy { it.currency } val reversalsByCurrency = reversalsInInterval.groupBy { it.currency } - val nonCzkCurrencies = (dividendsByCurrency.keys + taxesByCurrency.keys + reversalsByCurrency.keys) + // Foreign per-currency sections cover every non-CZK currency that has any activity. CZK taxes + // and reversals are routed to the Czech-source section below alongside CZ-country dividends. + val nonCzkCurrencies = (foreignDividendsByCurrency.keys + taxesByCurrency.keys + reversalsByCurrency.keys) .filter { it != Currency.CZK } .sortedBy { it.name } val sections = nonCzkCurrencies.map { currency -> buildCurrencySection( currency, - dividendsByCurrency[currency].orEmpty(), + foreignDividendsByCurrency[currency].orEmpty(), taxesByCurrency[currency].orEmpty(), reversalsByCurrency[currency].orEmpty(), exchangeRateProvider ) } - val czkSection = if (Currency.CZK in dividendsByCurrency.keys || Currency.CZK in taxesByCurrency.keys || Currency.CZK in reversalsByCurrency.keys) { - buildCzkSection( - dividendsByCurrency[Currency.CZK].orEmpty(), - taxesByCurrency[Currency.CZK].orEmpty(), - reversalsByCurrency[Currency.CZK].orEmpty() - ) + val czkTaxes = taxesByCurrency[Currency.CZK].orEmpty() + val czkReversals = reversalsByCurrency[Currency.CZK].orEmpty() + val czkSection = if (czDividends.isNotEmpty() || czkTaxes.isNotEmpty() || czkReversals.isNotEmpty()) { + buildCzkSection(czDividends, czkTaxes, czkReversals) } else null return DividendReport(sections, czkSection) diff --git a/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt index e58156f..b222b6d 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt @@ -85,7 +85,10 @@ object EtoroXlsxParser { LOGGER.warning("eToro: skipping non-positive dividend row $rowNum in $fileName (net=$net wht=$wht)") continue } - dividends.add(DividendRecord(date, gross, Currency.USD, symbol = instrument, broker = BROKER_NAME)) + // The eToro dividends sheet does not expose ISIN; default to "US" since virtually all + // eToro dividend payers are NYSE/Nasdaq listings. Non-US tickers (e.g. VOD.L) would be + // misclassified as US-source — track separately if this becomes material. + dividends.add(DividendRecord(date, gross, Currency.USD, symbol = instrument, broker = BROKER_NAME, country = "US")) if (wht > 0.0) { taxes.add(TaxRecord(date, -wht, Currency.USD)) } diff --git a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt index c7c9cac..a846602 100644 --- a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt @@ -66,7 +66,8 @@ class JsonExportParser { it.date, it.amount, symbol = it.symbol, - broker = BROKER + broker = BROKER, + country = "US" ) }, export.transactions.filterIsInstance().map { diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt index e787b67..6a80f54 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -75,7 +75,8 @@ object RevolutParser { val gross = amount / (1.0 - whtRate) val wht = gross - amount val ticker = record.get("Ticker").trim() - dividends.add(DividendRecord(date, gross, currency, symbol = ticker, broker = BROKER_NAME)) + // Revolut Stocks supports US-listed shares only, so the ISIN prefix is always "US". + dividends.add(DividendRecord(date, gross, currency, symbol = ticker, broker = BROKER_NAME, country = "US")) if (wht > 0.0) { taxes.add(TaxRecord(date, -wht, currency)) } diff --git a/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt index 7397de8..8841b94 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt @@ -40,7 +40,7 @@ class DegiroAccountStatementParserTest { val result = DegiroAccountStatementParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2024, 3, 15), 12.34, Currency.USD, symbol = "APPLE INC", broker = "Degiro") + DividendRecord(LocalDate(2024, 3, 15), 12.34, Currency.USD, symbol = "APPLE INC", broker = "Degiro", country = "US") ) assertThat(result.taxRecords).containsExactly( TaxRecord(LocalDate(2024, 3, 15), -1.85, Currency.USD) @@ -58,7 +58,7 @@ class DegiroAccountStatementParserTest { val result = DegiroAccountStatementParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2024, 3, 15), 10.00, Currency.USD, symbol = "APPLE INC", broker = "Degiro") + DividendRecord(LocalDate(2024, 3, 15), 10.00, Currency.USD, symbol = "APPLE INC", broker = "Degiro", country = "US") ) } @@ -74,8 +74,8 @@ class DegiroAccountStatementParserTest { val result = DegiroAccountStatementParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2024, 4, 1), 5.00, Currency.EUR, symbol = "ASML HOLDING", broker = "Degiro"), - DividendRecord(LocalDate(2024, 4, 2), 1234.56, Currency.CZK, symbol = "CEZ AS", broker = "Degiro") + DividendRecord(LocalDate(2024, 4, 1), 5.00, Currency.EUR, symbol = "ASML HOLDING", broker = "Degiro", country = "NL"), + DividendRecord(LocalDate(2024, 4, 2), 1234.56, Currency.CZK, symbol = "CEZ AS", broker = "Degiro", country = "CZ") ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt index 8afdeb5..2499d30 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt @@ -20,7 +20,7 @@ class DividendXlsxParserTest { val result = DividendXlsxParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2025, 10, 22), 58.22, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.") + DividendRecord(LocalDate(2025, 10, 22), 58.22, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.", country = "US") ) assertThat(result.taxRecords).containsExactly( TaxRecord(LocalDate(2025, 10, 22), -8.73) @@ -50,7 +50,7 @@ class DividendXlsxParserTest { val result = DividendXlsxParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2025, 10, 22), 58.22, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.") + DividendRecord(LocalDate(2025, 10, 22), 58.22, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.", country = "US") ) assertThat(result.taxRecords).containsExactly( TaxRecord(LocalDate(2025, 10, 22), -8.73) diff --git a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt index 0ddc0aa..e8c28c3 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt @@ -60,7 +60,7 @@ class DividentReportPreparationTest { // A dividend without a tax row almost always means a parser bug or a missed tax row in the // broker statement. Force the user to investigate rather than silently under-reporting. val dividends = listOf( - DividendRecord(LocalDate(2025, 6, 15), 500.0, symbol = "ACME", broker = "Schwab") + DividendRecord(LocalDate(2025, 6, 15), 500.0, symbol = "ACME", broker = "Schwab", country = "US") ) assertThatThrownBy { diff --git a/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt index 8226d62..c0af409 100644 --- a/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt @@ -31,8 +31,8 @@ class EtoroXlsxParserTest { val result = EtoroXlsxParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2025, 2, 1), 1.00, Currency.USD, symbol = "AAPL", broker = "eToro"), - DividendRecord(LocalDate(2025, 6, 15), 5.00, Currency.USD, symbol = "VOD.L", broker = "eToro"), + DividendRecord(LocalDate(2025, 2, 1), 1.00, Currency.USD, symbol = "AAPL", broker = "eToro", country = "US"), + DividendRecord(LocalDate(2025, 6, 15), 5.00, Currency.USD, symbol = "VOD.L", broker = "eToro", country = "US"), ) assertThat(result.taxRecords).containsExactly( TaxRecord(LocalDate(2025, 2, 1), -0.15, Currency.USD), @@ -75,7 +75,7 @@ class EtoroXlsxParserTest { val result = EtoroXlsxParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2025, 1, 2), 1.00, Currency.USD, symbol = "AAPL", broker = "eToro") + DividendRecord(LocalDate(2025, 1, 2), 1.00, Currency.USD, symbol = "AAPL", broker = "eToro", country = "US") ) assertThat(result.taxRecords).containsExactly( TaxRecord(LocalDate(2025, 1, 2), -0.15, Currency.USD) diff --git a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt index df97793..c81462e 100644 --- a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt @@ -45,7 +45,8 @@ class JsonExportParserTest { LocalDate(2023,10,25), 84.38, symbol = "CSCO", - broker = "Charles Schwab & Co." + broker = "Charles Schwab & Co.", + country = "US" ) ), listOf( diff --git a/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt index d434c24..c6c60b0 100644 --- a/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt +++ b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt @@ -14,12 +14,14 @@ fun dividendRecord( currency: Currency = Currency.USD, symbol: String = "TEST", broker: String = "TestBroker", + country: String = "US", ): DividendRecord = DividendRecord( date = date, amount = amount, currency = currency, symbol = symbol, broker = broker, + country = country, ) fun interestRecord( From 003a0e9ef79d922c6a806b4fc66f3d5d3ac1a2a0 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 21:42:57 +0200 Subject: [PATCH 20/31] get rid of default values --- .../cz/solutions/cockroach/DividendRecord.kt | 2 +- .../solutions/cockroach/DividendXlsxParser.kt | 4 +- .../cz/solutions/cockroach/EsppRecord.kt | 4 +- .../cz/solutions/cockroach/InterestRecord.kt | 4 +- .../solutions/cockroach/JsonExportParser.kt | 7 +- .../cz/solutions/cockroach/RevolutParser.kt | 2 +- .../cz/solutions/cockroach/RsuRecord.kt | 4 +- .../cz/solutions/cockroach/SaleRecord.kt | 4 +- .../cz/solutions/cockroach/TaxRecord.kt | 2 +- .../solutions/cockroach/TaxReversalRecord.kt | 2 +- .../cockroach/VubInterestPdfParser.kt | 2 +- .../cockroach/DividendXlsxParserTest.kt | 8 +- .../DividentReportPreparationTest.kt | 12 +-- .../cockroach/ETradeGainLossParserTest.kt | 8 +- .../cockroach/JsonExportParserTest.kt | 7 +- .../cockroach/SalesReportPreparationTest.kt | 8 +- .../cz/solutions/cockroach/TestRecords.kt | 74 +++++++++++++++++++ 17 files changed, 117 insertions(+), 37 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt index 4657f41..a5f7244 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt @@ -5,7 +5,7 @@ import org.joda.time.LocalDate data class DividendRecord( val date: LocalDate, val amount: Double, - val currency: Currency = Currency.USD, + val currency: Currency, val symbol: String, val broker: String, /** ISO 3166-1 alpha-2 country of issuer, derived from the first two letters of the ISIN. diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt index 59c6c0a..0e121f2 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt @@ -46,9 +46,9 @@ object DividendXlsxParser { val symbol = description.replace(DESCRIPTION_TAIL, "").trim() if (description.contains("WITHHOLDING", ignoreCase = true)) { - taxes.add(TaxRecord(date, value)) + taxes.add(TaxRecord(date, value, Currency.USD)) } else { - dividends.add(DividendRecord(date, value, symbol = symbol, broker = BROKER_NAME, country = "US")) + dividends.add(DividendRecord(date, value, Currency.USD, symbol = symbol, broker = BROKER_NAME, country = "US")) } } } diff --git a/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt b/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt index 42a0a64..3d2890c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt @@ -9,6 +9,6 @@ data class EsppRecord( val subscriptionFmv: Double, val purchaseFmv: Double, val purchaseDate: LocalDate, - val symbol: String = "", - val broker: String = "", + val symbol: String, + val broker: String, ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt index ffbdf34..c13c910 100644 --- a/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt @@ -5,11 +5,11 @@ import org.joda.time.LocalDate data class InterestRecord( val date: LocalDate, val amount: Double, - val currency: Currency = Currency.USD, + val currency: Currency, val product: String, val broker: String, /** Withholding tax already deducted at source, stored as a non-negative value in the source currency. */ - val tax: Double = 0.0, + val tax: Double, /** ISO 3166-1 alpha-2 country code identifying the source of the interest income (e.g. "IE", "SK", "CZ"). */ val country: String, ) diff --git a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt index a846602..b68ba13 100644 --- a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt @@ -65,6 +65,7 @@ class JsonExportParser { DividendRecord( it.date, it.amount, + Currency.USD, symbol = it.symbol, broker = BROKER, country = "US" @@ -73,13 +74,15 @@ class JsonExportParser { export.transactions.filterIsInstance().map { TaxRecord( it.date, - it.amount + it.amount, + Currency.USD ) }, export.transactions.filterIsInstance().map { TaxReversalRecord( it.date, - it.amount + it.amount, + Currency.USD ) }, diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt index 6a80f54..642ebaf 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -129,7 +129,7 @@ object RevolutParser { description.startsWith("Interest PAID") -> { if (value > 0.0) { val product = ISIN_PATTERN.find(description)?.value ?: description - interestRecords.add(InterestRecord(date, value, currency, product = product, broker = BROKER_NAME, country = SAVINGS_COUNTRY)) + interestRecords.add(InterestRecord(date, value, currency, product = product, broker = BROKER_NAME, tax = 0.0, country = SAVINGS_COUNTRY)) } } description.startsWith("Service Fee Charged") -> { diff --git a/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt b/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt index 35b339a..816ae30 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt @@ -8,6 +8,6 @@ data class RsuRecord( val vestFmv: Double, val vestDate: LocalDate, val grantId: String, - val symbol: String = "", - val broker: String = "", + val symbol: String, + val broker: String, ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/SaleRecord.kt b/src/main/kotlin/cz/solutions/cockroach/SaleRecord.kt index 529a58e..6605aae 100644 --- a/src/main/kotlin/cz/solutions/cockroach/SaleRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/SaleRecord.kt @@ -11,8 +11,8 @@ data class SaleRecord( val purchaseFmv: Double, val purchaseDate: LocalDate, val grantId: String?, - val symbol: String = "", - val broker: String = "", + val symbol: String, + val broker: String, ) { fun isTaxable(): Boolean { return date.isBefore(purchaseDate.plusYears(3)) diff --git a/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt b/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt index 4d2d477..2905ebf 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt @@ -5,5 +5,5 @@ import org.joda.time.LocalDate data class TaxRecord( val date: LocalDate, val amount: Double, - val currency: Currency = Currency.USD + val currency: Currency, ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt b/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt index 267b6cc..659c5e6 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt @@ -5,5 +5,5 @@ import org.joda.time.LocalDate data class TaxReversalRecord( val date: LocalDate, val amount: Double, - val currency: Currency = Currency.USD + val currency: Currency, ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt index 4f6331c..0997c91 100644 --- a/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt @@ -79,7 +79,7 @@ object VubInterestPdfParser { if (amount <= 0.0) { skipped++; continue } - records.add(InterestRecord(date, amount, Currency.CZK, product = product, broker = BROKER_NAME, country = COUNTRY)) + records.add(InterestRecord(date, amount, Currency.CZK, product = product, broker = BROKER_NAME, tax = 0.0, country = COUNTRY)) } LOGGER.info("VÚB: parsed ${records.size} interest record(s) from $fileName (skipped=$skipped)") return records diff --git a/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt index 2499d30..8e50516 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt @@ -20,10 +20,10 @@ class DividendXlsxParserTest { val result = DividendXlsxParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2025, 10, 22), 58.22, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.", country = "US") + DividendRecord(LocalDate(2025, 10, 22), 58.22, Currency.USD, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.", country = "US") ) assertThat(result.taxRecords).containsExactly( - TaxRecord(LocalDate(2025, 10, 22), -8.73) + TaxRecord(LocalDate(2025, 10, 22), -8.73, Currency.USD) ) } @@ -50,10 +50,10 @@ class DividendXlsxParserTest { val result = DividendXlsxParser.parse(file) assertThat(result.dividendRecords).containsExactly( - DividendRecord(LocalDate(2025, 10, 22), 58.22, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.", country = "US") + DividendRecord(LocalDate(2025, 10, 22), 58.22, Currency.USD, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.", country = "US") ) assertThat(result.taxRecords).containsExactly( - TaxRecord(LocalDate(2025, 10, 22), -8.73) + TaxRecord(LocalDate(2025, 10, 22), -8.73, Currency.USD) ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt index e8c28c3..a817489 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt @@ -21,8 +21,8 @@ class DividentReportPreparationTest { dividendRecord(LocalDate(2025, 10, 22), 59.04) // E-Trade ) val taxes = listOf( - TaxRecord(LocalDate(2025, 10, 22), -428.04), // Schwab (30% of 1426.80) - TaxRecord(LocalDate(2025, 10, 22), -8.86) // E-Trade (15% of 59.04) + taxRecord(LocalDate(2025, 10, 22), -428.04), // Schwab (30% of 1426.80) + taxRecord(LocalDate(2025, 10, 22), -8.86) // E-Trade (15% of 59.04) ) val report = DividentReportPreparation.generateDividendReport( @@ -44,7 +44,7 @@ class DividentReportPreparationTest { @Test fun singleDividendWithSingleTaxOnSameDate() { val dividends = listOf(dividendRecord(LocalDate(2025, 1, 22), 1000.0)) - val taxes = listOf(TaxRecord(LocalDate(2025, 1, 22), -150.0)) + val taxes = listOf(taxRecord(LocalDate(2025, 1, 22), -150.0)) val report = DividentReportPreparation.generateDividendReport( dividends, taxes, emptyList(), year2025, fixedRate @@ -60,7 +60,7 @@ class DividentReportPreparationTest { // A dividend without a tax row almost always means a parser bug or a missed tax row in the // broker statement. Force the user to investigate rather than silently under-reporting. val dividends = listOf( - DividendRecord(LocalDate(2025, 6, 15), 500.0, symbol = "ACME", broker = "Schwab", country = "US") + DividendRecord(LocalDate(2025, 6, 15), 500.0, Currency.USD, symbol = "ACME", broker = "Schwab", country = "US") ) assertThatThrownBy { @@ -84,8 +84,8 @@ class DividentReportPreparationTest { dividendRecord(LocalDate(2025, 4, 23), 1200.0) ) val taxes = listOf( - TaxRecord(LocalDate(2025, 1, 22), -150.0), - TaxRecord(LocalDate(2025, 4, 23), -180.0) + taxRecord(LocalDate(2025, 1, 22), -150.0), + taxRecord(LocalDate(2025, 4, 23), -180.0) ) val report = DividentReportPreparation.generateDividendReport( diff --git a/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt index 10df2be..40ccd83 100644 --- a/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt @@ -68,21 +68,21 @@ class ETradeGainLossParserTest { @Test fun `merging ParsedExports concatenates all record lists`() { val schwab = ParsedExport( - rsuRecords = listOf(RsuRecord(LocalDate(2023, 12, 10), 2, 48.38, LocalDate(2023, 12, 10), "1461994")), + rsuRecords = listOf(rsuRecord(LocalDate(2023, 12, 10), 2, 48.38, LocalDate(2023, 12, 10), "1461994")), esppRecords = emptyList(), dividendRecords = listOf(dividendRecord(LocalDate(2023, 10, 25), 84.38)), taxRecords = emptyList(), taxReversalRecords = emptyList(), - saleRecords = listOf(SaleRecord(LocalDate(2023, 9, 27), "RS", 30.0, 47.62, 43.91, 43.91, LocalDate(2022, 11, 10), "1538646")), + saleRecords = listOf(saleRecord(LocalDate(2023, 9, 27), "RS", 30.0, 47.62, 43.91, 43.91, LocalDate(2022, 11, 10), "1538646")), journalRecords = emptyList() ) val eTrade = ParsedExport( - rsuRecords = listOf(RsuRecord(LocalDate(2025, 3, 15), 10, 50.00, LocalDate(2025, 3, 15), "9990001")), + rsuRecords = listOf(rsuRecord(LocalDate(2025, 3, 15), 10, 50.00, LocalDate(2025, 3, 15), "9990001")), esppRecords = emptyList(), dividendRecords = emptyList(), taxRecords = emptyList(), taxReversalRecords = emptyList(), - saleRecords = listOf(SaleRecord(LocalDate(2025, 3, 16), "RS", 10.0, 52.00, 50.00, 50.00, LocalDate(2025, 3, 15), "9990001")), + saleRecords = listOf(saleRecord(LocalDate(2025, 3, 16), "RS", 10.0, 52.00, 50.00, 50.00, LocalDate(2025, 3, 15), "9990001")), journalRecords = emptyList() ) diff --git a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt index c81462e..7cdd274 100644 --- a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt @@ -44,6 +44,7 @@ class JsonExportParserTest { DividendRecord( LocalDate(2023,10,25), 84.38, + Currency.USD, symbol = "CSCO", broker = "Charles Schwab & Co.", country = "US" @@ -52,13 +53,15 @@ class JsonExportParserTest { listOf( TaxRecord( LocalDate(2023,10,25), - -12.66 + -12.66, + Currency.USD ) ), listOf( TaxReversalRecord( LocalDate(2023,2,7), - 1.88 + 1.88, + Currency.USD ) ), listOf( diff --git a/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt index c5f2b04..6c29e12 100644 --- a/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt @@ -11,7 +11,7 @@ class SalesReportPreparationTest { fun `old purchases still taken account for 100K limit`() { val salesReport = SalesReportPreparation.generateSalesReport( listOf( - SaleRecord( + saleRecord( LocalDate.parse("2021-06-30"), "ESPP", 20.0, @@ -21,7 +21,7 @@ class SalesReportPreparationTest { LocalDate.parse("2020-06-30"), null ), - SaleRecord( + saleRecord( LocalDate.parse("2021-06-30"), "ESPP", 400.0, @@ -49,7 +49,7 @@ class SalesReportPreparationTest { fun `loss in last 3 years is distracted from profit`() { val salesReport = SalesReportPreparation.generateSalesReport( listOf( - SaleRecord( + saleRecord( LocalDate.parse("2021-06-30"), "ESPP", 20.0, @@ -59,7 +59,7 @@ class SalesReportPreparationTest { LocalDate.parse("2020-06-30"), null ), - SaleRecord( + saleRecord( LocalDate.parse("2021-06-15"), "ESPP", 40.0, diff --git a/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt index c6c60b0..43c58ad 100644 --- a/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt +++ b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt @@ -41,3 +41,77 @@ fun interestRecord( tax = tax, country = country, ) + +fun taxRecord( + date: LocalDate, + amount: Double, + currency: Currency = Currency.USD, +): TaxRecord = TaxRecord(date = date, amount = amount, currency = currency) + +fun taxReversalRecord( + date: LocalDate, + amount: Double, + currency: Currency = Currency.USD, +): TaxReversalRecord = TaxReversalRecord(date = date, amount = amount, currency = currency) + +fun saleRecord( + date: LocalDate, + type: String, + quantity: Double, + salePrice: Double, + purchasePrice: Double, + purchaseFmv: Double, + purchaseDate: LocalDate, + grantId: String?, + symbol: String = "TEST", + broker: String = "TestBroker", +): SaleRecord = SaleRecord( + date = date, + type = type, + quantity = quantity, + salePrice = salePrice, + purchasePrice = purchasePrice, + purchaseFmv = purchaseFmv, + purchaseDate = purchaseDate, + grantId = grantId, + symbol = symbol, + broker = broker, +) + +fun rsuRecord( + date: LocalDate, + quantity: Int, + vestFmv: Double, + vestDate: LocalDate, + grantId: String, + symbol: String = "TEST", + broker: String = "TestBroker", +): RsuRecord = RsuRecord( + date = date, + quantity = quantity, + vestFmv = vestFmv, + vestDate = vestDate, + grantId = grantId, + symbol = symbol, + broker = broker, +) + +fun esppRecord( + date: LocalDate, + quantity: Double, + purchasePrice: Double, + subscriptionFmv: Double, + purchaseFmv: Double, + purchaseDate: LocalDate, + symbol: String = "TEST", + broker: String = "TestBroker", +): EsppRecord = EsppRecord( + date = date, + quantity = quantity, + purchasePrice = purchasePrice, + subscriptionFmv = subscriptionFmv, + purchaseFmv = purchaseFmv, + purchaseDate = purchaseDate, + symbol = symbol, + broker = broker, +) From 2514815507f83998740f9623e461a0897623ddb1 Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 22:56:34 +0200 Subject: [PATCH 21/31] update readme --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fdf9dfe..0651188 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # Cockroach will help you with your taxes This small utility is for people using [Charles Schwab brokerage](https://www.schwab.com/), -[E-Trade](https://www.etrade.com/), [Degiro](https://www.degiro.com/) and/or -[Revolut](https://www.revolut.com/) services in the +[E-Trade](https://www.etrade.com/), [Degiro](https://www.degiro.com/), +[Revolut](https://www.revolut.com/), [eToro](https://www.etoro.com/) and/or +[VÚB](https://www.vub.sk/) services in the [Czech Republic](https://en.wikipedia.org/wiki/Czech_Republic). The program reads the Schwab JSON export of your stock transactions, optionally an E-Trade Gain and Loss -XLSX/CSV export, optionally a Degiro account statement (`.xls`), and optionally Revolut Stocks and -Flexible Cash Funds CSV statements, then creates a summary of your sales, purchases and dividends for -the tax year. +XLSX/CSV export, optionally a Degiro account statement (`.xls`), optionally Revolut Stocks and +Flexible Cash Funds CSV statements, optionally an eToro account-statement XLSX, and optionally one or +more VÚB CZK account-statement PDFs, then creates a summary of your sales, purchases, dividends and +interest for the tax year. All input files referenced in this README (and the YAML config) are assumed to live under the `input/` folder at the repository root (which is git-ignored). See the [Input layout](#input-layout) @@ -147,6 +149,51 @@ Notes: - Each statement is a single-currency file; the currency is auto-detected from the `Value, ` column header. +# Obtaining eToro account statement + +1. Log in to the [eToro web platform](https://www.etoro.com/) and open + **Portfolio → ⚙ (Settings) → Account → Account Statement**. + +2. Set the date range to cover the relevant tax year (overlap of a few days at both ends is + safe; only rows whose payment date falls inside the requested year contribute to the + report). + +3. Select dates for the whole last year and then export as **XLSX** (Excel) and save the file + into `input/etoro/` (e.g.`input/etoro/etoro-account-statement-2025.xlsx`). + +Notes: +- Only the **Dividends** sheet is read. The parser converts each row into a gross + `DividendRecord` (= net + WHT) and a matching negative `TaxRecord` (= -WHT). +- Amounts on the eToro Dividends sheet are reported in **USD**; daily CNB USD/CZK rates + are used for FX conversion. +- The dividends sheet does not expose ISIN, so every eToro dividend is currently + classified as **US-source**. Non-US tickers (e.g. `VOD.L`) would be misclassified — if + you trade those on eToro, treat the resulting Příloha č. 3 numbers with care. +- Rows whose gross amount is non-positive are skipped with a warning on stdout. + +# Obtaining VÚB account statement + +[VÚB](https://www.vub.sk/) is used here only as a source of **CZK credit interest** on a +regular bank account. The bank does not expose an export, so the official monthly / +yearly account-statement PDFs are the only data source. + +1. Log in to VÚB Internet Banking and download the account statements that cover the + relevant tax year for your **CZK** account. + +2. Save the PDFs into `input/vub/` (e.g. `input/vub/SK1234567890123456789012_2025.pdf`). + The IBAN in the file name (or in the statement body) is used as the *Product* + identifier on the interest report. + +Notes: +- Only **CZK** statements are accepted; the parser fails fast on non-CZK files + (`Currency: CZK` must appear in the statement header). +- Only `Credit interest` (English) and `Úroky pripísané` (Slovak) postings whose + reference matches the `NNNNIGNNNN…` pattern are taken as gross §8 interest income. + Non-standard rows are skipped with a warning on stdout. +- VÚB does not show withholding tax on the statement; the resulting `InterestRecord`s + carry `tax = 0`. The country is set to `SK`, so these payments correctly land on + Příloha č. 3 (foreign-source interest), not on the Czech "konečné zdanění" page. + # Input layout All inputs (broker exports and the YAML config) live under `input/`. A typical layout is: @@ -162,9 +209,13 @@ input/ │ ├── espp/ *.pdf # ESPP purchase confirmations (skipped when etradeBenefitHistory is configured) │ ├── dividends/ *.xlsx # single dividends export │ └── sales/ *.xlsx # single Gain & Loss export -└── revolut/ # Revolut CSV statements - ├── trading-account-statement_*.csv # Stocks (dividends) - └── savings-statement_*.csv # Flexible Cash Funds (one per currency) +├── revolut/ # Revolut CSV statements +│ ├── trading-account-statement_*.csv # Stocks (dividends) +│ └── savings-statement_*.csv # Flexible Cash Funds (one per currency) +├── etoro/ # eToro account-statement XLSX files +│ └── etoro-account-statement-*.xlsx +└── vub/ # VÚB CZK account-statement PDFs + └── SK*_*.pdf ``` Each broker is optional; include only what applies to you. @@ -192,13 +243,17 @@ revolut: # optional Revolut block savings: # list of Flexible Cash Funds CSV statements (one per currency) - ./input/revolut/savings-statement_2024-01-01_2024-12-31_en-us_USD_xxxxxx.csv - ./input/revolut/savings-statement_2024-01-01_2024-12-31_en-us_EUR_xxxxxx.csv +etoro: # optional, list of eToro account-statement .xlsx files + - ./input/etoro/etoro-account-statement-2025.xlsx +vub: # optional, list of VÚB CZK account-statement .pdf files + - ./input/vub/SK1234567890123456789012_2025.pdf ``` ``` java -jar target/cockroach-0.3-SNAPSHOT.jar input/config.yaml ``` -At least one of `schwab`, `etrade`, `degiro`, `revolut.stocks`, `revolut.savings` must be present. +At least one of `schwab`, `etrade`, `degiro`, `revolut.stocks`, `revolut.savings`, `etoro`, `vub` must be present. ## Positional arguments (legacy, Schwab + E-Trade only) @@ -230,7 +285,7 @@ With E-Trade data: java -jar target/cockroach-0.3-SNAPSHOT.jar input/schwab-export.json 2025 ./output input/etrade -With YAML config (Schwab and/or E-Trade and/or Degiro and/or Revolut): +With YAML config (Schwab and/or E-Trade and/or Degiro and/or Revolut and/or eToro and/or VÚB): java -jar target/cockroach-0.3-SNAPSHOT.jar input/config.yaml From 1b3ccbdb80f2c445efeb33fbc41c3ffe5440792d Mon Sep 17 00:00:00 2001 From: jimarek Date: Sun, 26 Apr 2026 22:57:13 +0200 Subject: [PATCH 22/31] converting to pdf is obsolete --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index 0651188..6c1f908 100644 --- a/README.md +++ b/README.md @@ -288,10 +288,3 @@ java -jar target/cockroach-0.3-SNAPSHOT.jar input/schwab-export.json 2025 ./outp With YAML config (Schwab and/or E-Trade and/or Degiro and/or Revolut and/or eToro and/or VÚB): java -jar target/cockroach-0.3-SNAPSHOT.jar input/config.yaml - -# Converting to PDF - -- pandoc sales_2021.md -V geometry:landscape - \--pdf-engine=/Library/TeX/texbin/pdflatex -o sales.pdf - -- IDEA: Tools -\> Markdown Converter From 98e0dffe1a9cae64227a49d316e0d4b115483690 Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 18:45:49 +0200 Subject: [PATCH 23/31] small fixes: CNB timeouts + sanity check, eToro non-US warning, drop unused imports * HttpCnbYearRatesSource: switch to URLConnection with explicit connectTimeout=10s / readTimeout=60s so a CNB outage no longer hangs the whole run; require the response to contain a 'Date|'/'Datum|' header line before returning or caching it, so a captive-portal page or HTML error body fails loudly instead of being silently cached as empty rates. * EtoroXlsxParser: log a per-row WARNING when an instrument name carries a non-US exchange suffix (e.g. VOD.L, SAP.DE, MC.PA), since country is hard-coded to US and such tickers would be misclassified as US-source dividends. * ETradeBrokerSource, CockroachMain: drop genuinely unused imports (org.apache.poi.openxml4j.opc.internal.FileHelper and java.util.logging.Logger respectively). --- .../solutions/cockroach/CnbYearRatesSource.kt | 32 +++++++++++++------ .../cz/solutions/cockroach/CockroachMain.kt | 1 - .../solutions/cockroach/ETradeBrokerSource.kt | 1 - .../cz/solutions/cockroach/EtoroXlsxParser.kt | 13 +++++++- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt index 8d9376a..e53480e 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt @@ -86,19 +86,12 @@ class HttpCnbYearRatesSource( return !today().isBefore(safeAfter) } - private fun downloadFresh(year: Int): String { - val url = URI("$baseUrl?year=$year").toURL() - LOGGER.info("downloading CNB rates for $year from $url (incomplete year, not cached)") - return url.openStream().use { it.reader(StandardCharsets.UTF_8).readText() } - } + private fun downloadFresh(year: Int): String = download(year, cached = false) private fun downloadToFile(year: Int, target: File) { - val url = URI("$baseUrl?year=$year").toURL() - LOGGER.info("downloading CNB rates for $year from $url") + val content = download(year, cached = true) val tmp = File(target.parentFile, "${target.name}.tmp") - url.openStream().use { ins -> - tmp.outputStream().use { out -> ins.copyTo(out) } - } + tmp.writeText(content, StandardCharsets.UTF_8) if (target.exists() && !target.delete()) { throw IllegalStateException("could not replace cached file ${target.absolutePath}") } @@ -107,6 +100,22 @@ class HttpCnbYearRatesSource( } } + private fun download(year: Int, cached: Boolean): String { + val url = URI("$baseUrl?year=$year").toURL() + val suffix = if (cached) "" else " (incomplete year, not cached)" + LOGGER.info("downloading CNB rates for $year from $url$suffix") + val connection = url.openConnection().apply { + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + } + val content = connection.getInputStream().use { it.reader(StandardCharsets.UTF_8).readText() } + require(content.lines().any { isHeaderLine(it) }) { + "CNB response for $year does not contain a 'Date|' / 'Datum|' header line; refusing to use it. " + + "First 200 chars: '${content.take(200).replace("\n", "\\n")}'" + } + return content + } + private fun splitByHeader(content: String): List { val chunks = mutableListOf>() for (line in content.lines()) { @@ -128,6 +137,9 @@ class HttpCnbYearRatesSource( companion object { private val LOGGER = Logger.getLogger(HttpCnbYearRatesSource::class.java.name) + private const val CONNECT_TIMEOUT_MS = 10_000 + private const val READ_TIMEOUT_MS = 60_000 + const val DEFAULT_BASE_URL = "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/year.txt" diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index a4bbc00..681dff6 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -2,7 +2,6 @@ package cz.solutions.cockroach import java.io.File import java.nio.charset.StandardCharsets -import java.util.logging.Logger import kotlin.system.exitProcess fun main(args: Array) { diff --git a/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt index e723229..7945dbe 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt @@ -1,6 +1,5 @@ package cz.solutions.cockroach -import org.apache.poi.openxml4j.opc.internal.FileHelper import java.io.File /** diff --git a/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt index b222b6d..4500cf6 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt @@ -43,6 +43,10 @@ object EtoroXlsxParser { private val ATTR_R_PATTERN = Regex("""\br="([A-Z]+)\d+"""") private val ATTR_T_PATTERN = Regex("""\bt="([a-z]+)"""") + // Tickers carrying a non-US exchange suffix such as ".L" (LSE), ".DE" (Xetra), + // ".PA" (Paris) — used only for a per-row warning since we hard-code country = "US". + private val NON_US_TICKER_SUFFIX = Regex("""\.([A-Z]{1,3})\b""") + fun parse(file: File): EtoroParseResult { ZipFile(file).use { zip -> val strings = readSharedStrings(zip) @@ -87,7 +91,14 @@ object EtoroXlsxParser { } // The eToro dividends sheet does not expose ISIN; default to "US" since virtually all // eToro dividend payers are NYSE/Nasdaq listings. Non-US tickers (e.g. VOD.L) would be - // misclassified as US-source — track separately if this becomes material. + // misclassified as US-source — warn so the user can correct the report manually. + val suffix = NON_US_TICKER_SUFFIX.find(instrument)?.groupValues?.get(1) + if (suffix != null) { + LOGGER.warning( + "eToro: instrument '$instrument' on row $rowNum looks non-US (suffix .$suffix); " + + "treating as country=US — verify Příloha č. 3 vs § 16a routing manually" + ) + } dividends.add(DividendRecord(date, gross, Currency.USD, symbol = instrument, broker = BROKER_NAME, country = "US")) if (wht > 0.0) { taxes.add(TaxRecord(date, -wht, Currency.USD)) From 01638f15f10902cfae7684c5d2633b9c98a7123f Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 18:59:38 +0200 Subject: [PATCH 24/31] fix broker name on E-Trade PDFs; fail loud on unknown Revolut Interest rows A1 (likely-incorrect output): RsuPdfParser and EsppPdfParser previously hardcoded broker = "Charles Schwab & Co." because the PDF templates are identical for Schwab and E-Trade/Morgan Stanley. After the E-Trade source was added, RSU/ESPP records imported via ETradeBrokerSource still showed 'Charles Schwab & Co.' on Priloha c. 3 / sec. 6, conflicting with the sales/dividend rows from the same source. Pass brokerName explicitly through parse / parseDirectory / parseFromText; ETradeBrokerSource now stamps 'Morgan Stanley & Co.' to match what ETradeBenefitHistoryParser and ETradeGainLossParser already emit. B2 (robustness): RevolutParser.parseSavings only recognises English 'Interest PAID' / 'Interest Reinvested' descriptions. A localised statement (CZ/SK) or a new Revolut row type whose description starts with 'Interest ' would previously fall into the generic 'unrecognised row' warning branch and be silently dropped, under-reporting sec. 8 interest income. Add a fail-loud check that throws IllegalStateException on any 'Interest *' row not matching the known prefixes, with guidance to re-export the statement in English. Tests: RsuPdfParserTest / EsppPdfParserTest updated to thread brokerName through; new RevolutParserTest case asserts the IllegalStateException fires on 'Interest Accrued ...'. mvn test: 51 run, 0 failures, 1 skipped (external-PDF assumeTrue). --- .../solutions/cockroach/ETradeBrokerSource.kt | 10 +++++++-- .../cz/solutions/cockroach/EsppPdfParser.kt | 21 ++++++++++--------- .../cz/solutions/cockroach/RevolutParser.kt | 11 ++++++++++ .../cz/solutions/cockroach/RsuPdfParser.kt | 21 ++++++++++--------- .../solutions/cockroach/EsppPdfParserTest.kt | 14 +++++++------ .../solutions/cockroach/RevolutParserTest.kt | 15 +++++++++++++ .../solutions/cockroach/RsuPdfParserTest.kt | 20 ++++++++++-------- 7 files changed, 75 insertions(+), 37 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt index 7945dbe..4b9fec1 100644 --- a/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt @@ -13,6 +13,12 @@ class ETradeBrokerSource( ) : BrokerSource { override val name: String = "E-Trade" + // E-Trade was acquired by Morgan Stanley; the legal counterparty on Příloha č. 3 / § 6 reports + // is Morgan Stanley, matching what ETradeBenefitHistoryParser/ETradeGainLossParser emit. The + // RSU/ESPP PDFs themselves are still in the legacy Schwab template and carry no broker name, + // so we stamp them here. + private val brokerName: String = "Morgan Stanley & Co." + init { require(directory != null || benefitHistoryFile != null) { "E-Trade source needs either a directory or a benefit-history file" @@ -22,10 +28,10 @@ class ETradeBrokerSource( override fun parse(): ParsedExport { val benefitHistory = benefitHistoryFile?.let { ETradeBenefitHistoryParser.parse(it) } val rsuRecords = benefitHistory?.rsuRecords - ?: directory?.let { RsuPdfParser.parseDirectory(File(it, "rsu")) } + ?: directory?.let { RsuPdfParser.parseDirectory(File(it, "rsu"), brokerName) } ?: emptyList() val esppRecords = benefitHistory?.esppRecords - ?: directory?.let { EsppPdfParser.parseDirectory(File(it, "espp")) } + ?: directory?.let { EsppPdfParser.parseDirectory(File(it, "espp"), brokerName) } ?: emptyList() val dividendXlsxFile = directory?.let { locateSingleFile(File(it, "dividends"), "xlsx") } val dividendXlsxResult = dividendXlsxFile?.let { DividendXlsxParser.parse(it) } diff --git a/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt index 2abc593..d6121b9 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt @@ -4,27 +4,28 @@ import java.io.File object EsppPdfParser { - private const val BROKER_NAME = "Charles Schwab & Co." - // After "Company Name (Symbol)" the symbol appears in parentheses, possibly on the next line. private val SYMBOL_PATTERN = Regex("""Company Name \(Symbol\)[\s\S]*?\(([A-Z][A-Z0-9.]*)\)""") /** - * Parses a single ESPP Purchase Confirmation PDF and returns an EsppRecord. + * Parses a single ESPP Purchase Confirmation PDF and returns an EsppRecord stamped with [brokerName]. + * The PDF format itself does not identify the issuing broker (Schwab and E-Trade/Morgan Stanley both + * deliver Schwab-style Purchase Confirmations), so the caller must supply the broker name. */ - fun parse(pdfFile: File): EsppRecord { + fun parse(pdfFile: File, brokerName: String): EsppRecord { val text = PdfParserUtils.extractText(pdfFile) - return parseFromText(text) + return parseFromText(text, brokerName) } /** - * Parses all ESPP Purchase Confirmation PDFs in the given directory and returns a list of EsppRecords. + * Parses all ESPP Purchase Confirmation PDFs in the given directory and returns a list of EsppRecords + * stamped with [brokerName]. */ - fun parseDirectory(directory: File): List { - return PdfParserUtils.parseDirectory(directory, ::parse) + fun parseDirectory(directory: File, brokerName: String): List { + return PdfParserUtils.parseDirectory(directory) { parse(it, brokerName) } } - fun parseFromText(text: String): EsppRecord { + fun parseFromText(text: String, brokerName: String): EsppRecord { // "Purchase Date 12-31-2025Shares Purchased..." due to column merge val purchaseDate = PdfParserUtils.extractDate(text, "Purchase Date") val sharesPurchased = PdfParserUtils.extractDouble(text, "Shares Purchased") @@ -41,7 +42,7 @@ object EsppPdfParser { purchaseFmv = purchaseValuePerShare, purchaseDate = purchaseDate, symbol = symbol, - broker = BROKER_NAME + broker = brokerName ) } diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt index 642ebaf..544e9f7 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -48,6 +48,11 @@ object RevolutParser { // or regulatory shift). Fail loudly so we never silently under-report §8 income. private val SAVINGS_TAX_PATTERN = Regex("(?i)\\b(WHT|withholding|tax\\s+(?:withheld|deducted|paid|charged))\\b") + // Any row whose description starts with "Interest " (case-insensitive) but is not one of our + // known prefixes ("Interest PAID", "Interest Reinvested") signals either a localised statement + // or a new Revolut row type. Fail loudly rather than silently dropping cash interest. + private val SAVINGS_INTEREST_PATTERN = Regex("(?i)^Interest\\b") + fun parseStocks(file: File, whtRate: Double = DEFAULT_WHT_RATE): RevolutStocksParseResult { return file.reader(StandardCharsets.UTF_8).use { parseStocks(it, whtRate) } } @@ -145,6 +150,12 @@ object RevolutParser { "Parser assumes Flexible Account interest is gross (Irish UCITS, no WHT). " + "Investigate the statement manually before re-running." } + check(!SAVINGS_INTEREST_PATTERN.containsMatchIn(description)) { + "Revolut Savings: encountered unrecognised Interest row '$description' at $date. " + + "Parser only handles English 'Interest PAID' / 'Interest Reinvested'. " + + "If your statement is localised (e.g. CZ/SK), re-export it in English; " + + "otherwise investigate manually before re-running." + } LOGGER.warning("Revolut Savings: unrecognised row '$description' at $date; ignored.") } } diff --git a/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt index 72404a9..a191c0b 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt @@ -4,27 +4,28 @@ import java.io.File object RsuPdfParser { - private const val BROKER_NAME = "Charles Schwab & Co." - // After "Company Name (Symbol)" the symbol appears in parentheses, possibly on the next line. private val SYMBOL_PATTERN = Regex("""Company Name \(Symbol\)[\s\S]*?\(([A-Z][A-Z0-9.]*)\)""") /** - * Parses a single RSU Release Confirmation PDF and returns an RsuRecord. + * Parses a single RSU Release Confirmation PDF and returns an RsuRecord stamped with [brokerName]. + * The PDF format itself does not identify the issuing broker (Schwab and E-Trade/Morgan Stanley both + * deliver Schwab-style Release Confirmations), so the caller must supply the broker name. */ - fun parse(pdfFile: File): RsuRecord { + fun parse(pdfFile: File, brokerName: String): RsuRecord { val text = PdfParserUtils.extractText(pdfFile) - return parseFromText(text) + return parseFromText(text, brokerName) } /** - * Parses all RSU Release Confirmation PDFs in the given directory and returns a list of RsuRecords. + * Parses all RSU Release Confirmation PDFs in the given directory and returns a list of RsuRecords + * stamped with [brokerName]. */ - fun parseDirectory(directory: File): List { - return PdfParserUtils.parseDirectory(directory, ::parse) + fun parseDirectory(directory: File, brokerName: String): List { + return PdfParserUtils.parseDirectory(directory) { parse(it, brokerName) } } - fun parseFromText(text: String): RsuRecord { + fun parseFromText(text: String, brokerName: String): RsuRecord { // The PDF text has "Plan 05Release Date MM-dd-yyyy" due to column merge val releaseDate = PdfParserUtils.extractDate(text, "Release Date") val sharesReleased = PdfParserUtils.extractInt(text, "Shares Released") @@ -39,7 +40,7 @@ object RsuPdfParser { vestDate = releaseDate, grantId = awardNumber, symbol = symbol, - broker = BROKER_NAME + broker = brokerName ) } } diff --git a/src/test/kotlin/cz/solutions/cockroach/EsppPdfParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/EsppPdfParserTest.kt index 839d438..eb6f587 100644 --- a/src/test/kotlin/cz/solutions/cockroach/EsppPdfParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/EsppPdfParserTest.kt @@ -12,6 +12,8 @@ class EsppPdfParserTest { private val EXTERNAL_PDF = File("/Users/jandryse/Documents/dane/2026/input/e-trade/espp/getEsppConfirmation.pdf") } + private val brokerName = "Morgan Stanley & Co." + @Test fun parsesPurchaseConfirmationPdfText() { val text = """ @@ -33,7 +35,7 @@ class EsppPdfParserTest { (85.000% of $47.520000) $40.392000 """.trimIndent() - val record = EsppPdfParser.parseFromText(text) + val record = EsppPdfParser.parseFromText(text, brokerName) assertThat(record).isEqualTo( EsppRecord( @@ -44,7 +46,7 @@ class EsppPdfParserTest { purchaseFmv = 77.03, purchaseDate = LocalDate(2025, 12, 31), symbol = "CSCO", - broker = "Charles Schwab & Co." + broker = brokerName ) ) } @@ -70,7 +72,7 @@ class EsppPdfParserTest { (85.000% of $55.000000) $46.750000 """.trimIndent() - val record = EsppPdfParser.parseFromText(text) + val record = EsppPdfParser.parseFromText(text, brokerName) assertThat(record).isEqualTo( EsppRecord( @@ -81,7 +83,7 @@ class EsppPdfParserTest { purchaseFmv = 65.5, purchaseDate = LocalDate(2025, 6, 30), symbol = "ACME", - broker = "Charles Schwab & Co." + broker = brokerName ) ) } @@ -90,7 +92,7 @@ class EsppPdfParserTest { fun parsesActualPdf() { assumeTrue(EXTERNAL_PDF.exists(), "External PDF not available, skipping") - val record = EsppPdfParser.parse(EXTERNAL_PDF) + val record = EsppPdfParser.parse(EXTERNAL_PDF, brokerName) assertThat(record).isEqualTo( EsppRecord( @@ -101,7 +103,7 @@ class EsppPdfParserTest { purchaseFmv = 77.03, purchaseDate = LocalDate(2025, 12, 31), symbol = "CSCO", - broker = "Charles Schwab & Co." + broker = brokerName ) ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt index ddf5e62..93c9a61 100644 --- a/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt @@ -1,6 +1,7 @@ package cz.solutions.cockroach import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalStateException import org.assertj.core.data.Offset.offset import org.joda.time.LocalDate import org.junit.jupiter.api.Test @@ -94,6 +95,20 @@ class RevolutParserTest { assertThat(result.interestRecords[0].amount).isCloseTo(1.2345, offset(0.0001)) } + @Test + fun parseSavingsFailsLoudlyOnUnrecognisedInterestRow() { + // Localised statement (e.g. CZ): row begins with "Interest" but is not "PAID" / "Reinvested". + val csv = """ + Date,Description,"Value, USD","Value, CZK",FX Rate,Price per share,Quantity of shares + "Dec 31, 2025, 1:51:12 AM",Interest Accrued USD Class R IE000H9J0QX4,1.0000,21.0000,21.0000,, + """.trimIndent() + + assertThatIllegalStateException() + .isThrownBy { RevolutParser.parseSavings(StringReader(csv)) } + .withMessageContaining("unrecognised Interest row") + .withMessageContaining("Interest Accrued") + } + @Test fun parsesSavingsDateWithNarrowNoBreakSpaceBeforeAmPm() { // Real Revolut CSVs use U+202F (NARROW NO-BREAK SPACE) between time and AM/PM. diff --git a/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt index b29fe74..04ec7db 100644 --- a/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt @@ -8,6 +8,8 @@ import java.io.File class RsuPdfParserTest { + private val brokerName = "Morgan Stanley & Co." + private fun loadResourceAsFile(name: String): File { return File({}::class.java.getResource(name)!!.toURI()) } @@ -28,7 +30,7 @@ class RsuPdfParserTest { Award Price Per Share $0.000000 """.trimIndent() - val record = RsuPdfParser.parseFromText(text) + val record = RsuPdfParser.parseFromText(text, brokerName) assertThat(record).isEqualTo( RsuRecord( @@ -38,7 +40,7 @@ class RsuPdfParserTest { vestDate = LocalDate(2025, 12, 10), grantId = "1623675", symbol = "CSCO", - broker = "Charles Schwab & Co." + broker = brokerName ) ) } @@ -60,7 +62,7 @@ class RsuPdfParserTest { Award Price Per Share $0.000000 """.trimIndent() - val record = RsuPdfParser.parseFromText(text) + val record = RsuPdfParser.parseFromText(text, brokerName) assertThat(record).isEqualTo( RsuRecord( @@ -70,7 +72,7 @@ class RsuPdfParserTest { vestDate = LocalDate(2025, 9, 10), grantId = "1679633", symbol = "CSCO", - broker = "Charles Schwab & Co." + broker = brokerName ) ) } @@ -79,7 +81,7 @@ class RsuPdfParserTest { fun parsesSinglePdf() { val pdfFile = loadResourceAsFile("getReleaseConfirmation.pdf") - val record = RsuPdfParser.parse(pdfFile) + val record = RsuPdfParser.parse(pdfFile, brokerName) assertThat(record).isEqualTo( RsuRecord( @@ -89,7 +91,7 @@ class RsuPdfParserTest { vestDate = LocalDate(2025, 12, 10), grantId = "1623675", symbol = "CSCO", - broker = "Charles Schwab & Co." + broker = brokerName ) ) } @@ -103,7 +105,7 @@ class RsuPdfParserTest { pdfFile.copyTo(File(tempDir, "release2.pdf")) pdfFile.copyTo(File(tempDir, "release3.pdf")) - val records = RsuPdfParser.parseDirectory(tempDir) + val records = RsuPdfParser.parseDirectory(tempDir, brokerName) assertThat(records).hasSize(3) assertThat(records).allSatisfy { record -> @@ -115,7 +117,7 @@ class RsuPdfParserTest { vestDate = LocalDate(2025, 12, 10), grantId = "1623675", symbol = "CSCO", - broker = "Charles Schwab & Co." + broker = brokerName ) ) } @@ -128,7 +130,7 @@ class RsuPdfParserTest { pdfFile.copyTo(File(tempDir, "release1.pdf")) File(tempDir, "notes.txt").writeText("not a pdf") - val records = RsuPdfParser.parseDirectory(tempDir) + val records = RsuPdfParser.parseDirectory(tempDir, brokerName) assertThat(records).hasSize(1) } From a5a10458e5a0adbd5b88dc1c368c6c2a0c3f0523 Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 19:17:36 +0200 Subject: [PATCH 25/31] VUB: cross-check configured tax year against statement closing balance year A2 (likely-incorrect output): VubInterestPdfParser stamped every posting with the year passed in by the caller (typically tax_YYYY.yaml's year), even though VUB's table only prints DD/MM. Pointing the configuration at the wrong year (e.g. running with year=2024 against a 2025 statement, or vice versa around the December->January boundary) would silently mis-stamp every InterestRecord. Fix: extract the statement year from the 'Account balance as at DD/MM/YYYY' lines in the PDF header (max year across opening and closing balances = closing year = statement year), and require it to match the caller's configured year. Throw IllegalStateException with actionable guidance on mismatch. Tests: new VubInterestPdfParserTest covers extractStatementYear (synthetic text fixture for the closing-balance-pick rule and the no-balance-line error path) and exercises the cross-check via the bundled real PDF using assumeTrue, asserting both the mismatch (year=2024 -> throws) and the happy path (year=2025 -> records all carry 2025, CZK, broker='VUB'). mvn test: 55 run, 0 failures, 1 skipped. --- .../cockroach/VubInterestPdfParser.kt | 21 +++++- .../cockroach/VubInterestPdfParserTest.kt | 67 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/cz/solutions/cockroach/VubInterestPdfParserTest.kt diff --git a/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt index 0997c91..a03be83 100644 --- a/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt @@ -32,6 +32,11 @@ object VubInterestPdfParser { private val DATE_TOKEN = Regex("""\b(\d{2})/(\d{2})\b""") private val IG_REFERENCE = Regex("""^\d{4}IG\d+$""") private val IBAN_IN_FILENAME = Regex("""(SK\d{22})""") + // VÚB header always prints opening + closing balance lines like + // "Account balance as at 31/12/2024" (opening, prior year-end) + // "Account balance as at 31/12/2025" (closing, statement year-end) + // The maximum year across all matches is the statement year. + private val BALANCE_DATE = Regex("""Account balance as at \d{2}/\d{2}/(\d{4})""") fun parse(file: File, year: Int): List { Loader.loadPDF(file).use { doc -> @@ -42,9 +47,23 @@ object VubInterestPdfParser { require(text.contains("Currency: CZK", ignoreCase = true)) { "VÚB statement ${file.name} does not declare 'Currency: CZK' – non-CZK accounts are not supported." } + val statementYear = extractStatementYear(text, file.name) + check(statementYear == year) { + "VÚB statement ${file.name} covers year $statementYear but the configured tax year is $year. " + + "Postings in the PDF carry only DD/MM, so applying the wrong year would silently mis-stamp every record. " + + "Re-run with year=$statementYear or point the configuration at the matching statement." + } val product = extractProduct(file, text) - return parseText(text, year, product, file.name) + return parseText(text, statementYear, product, file.name) + } + } + + internal fun extractStatementYear(text: String, fileName: String): Int { + val years = BALANCE_DATE.findAll(text).map { it.groupValues[1].toInt() }.toList() + check(years.isNotEmpty()) { + "VÚB statement $fileName: cannot determine statement year – no 'Account balance as at DD/MM/YYYY' line found." } + return years.max() } private fun parseText(text: String, year: Int, product: String, fileName: String): List { diff --git a/src/test/kotlin/cz/solutions/cockroach/VubInterestPdfParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/VubInterestPdfParserTest.kt new file mode 100644 index 0000000..14f9f5c --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/VubInterestPdfParserTest.kt @@ -0,0 +1,67 @@ +package cz.solutions.cockroach + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalStateException +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import java.io.File + +class VubInterestPdfParserTest { + + companion object { + // Real VÚB statement bundled in input/ for end-to-end runs; tests using it + // are skipped when the file is absent (e.g. CI without private samples). + private val EXTERNAL_PDF = + File("input/2025/statement_account-VUB-2025.pdf") + } + + @Test + fun extractStatementYearPicksClosingBalanceYear() { + val text = """ + ACCOUNT STATEMENT + Currency: CZK + Account balance as at 31/12/2024 + 1.000,00 + Account balance as at 31/12/2025 + 2.000,00 + """.trimIndent() + + val year = VubInterestPdfParser.extractStatementYear(text, "fixture.pdf") + + assertThat(year).isEqualTo(2025) + } + + @Test + fun extractStatementYearThrowsWhenNoBalanceLineFound() { + val text = "ACCOUNT STATEMENT\nCurrency: CZK\n" + + assertThatIllegalStateException() + .isThrownBy { VubInterestPdfParser.extractStatementYear(text, "broken.pdf") } + .withMessageContaining("cannot determine statement year") + } + + @Test + fun parseFailsLoudlyWhenConfiguredYearDiffersFromStatementYear() { + assumeTrue(EXTERNAL_PDF.exists(), "External VÚB PDF not available, skipping") + + assertThatIllegalStateException() + .isThrownBy { VubInterestPdfParser.parse(EXTERNAL_PDF, year = 2024) } + .withMessageContaining("covers year 2025") + .withMessageContaining("configured tax year is 2024") + } + + @Test + fun parseSucceedsWhenConfiguredYearMatchesStatementYear() { + assumeTrue(EXTERNAL_PDF.exists(), "External VÚB PDF not available, skipping") + + val records = VubInterestPdfParser.parse(EXTERNAL_PDF, year = 2025) + + assertThat(records).isNotEmpty + assertThat(records).allSatisfy { r -> + assertThat(r.date.year).isEqualTo(2025) + assertThat(r.currency).isEqualTo(Currency.CZK) + assertThat(r.broker).isEqualTo("VÚB") + assertThat(r.amount).isPositive + } + } +} From 1614e7e1781581908307537925d8d59f2867beb9 Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 19:24:00 +0200 Subject: [PATCH 26/31] Revolut: fail loud on non-US ticker; document whtRate is per-broker not per-issuer A3 (likely-incorrect output): Revolut Stocks reports only net dividends and the parser performs a mathematical gross-up at a single per-broker whtRate (default 0.15 for the US/CZ treaty rate). Today this is correct because Revolut Stocks is a US-only product, but if Revolut adds non-US listings or pays out an ADR whose underlying issuer-country withholds at a different rate, the per-row gross-up will not match what was actually withheld and the user will silently mis-report taxes credited. Fix: - RevolutParser.parseStocks now throws IllegalStateException on any ticker carrying a non-US exchange suffix (.L, .DE, .PA, ...) instead of silently treating it as US, mirroring the existing fail-loud behaviour for unrecognised Interest rows in parseSavings. The error spells out the offending ticker, the per-broker whtRate that would have been mis-applied, and the actionable workaround (split the row out manually, or extend the parser with per-issuer routing). - KDoc on parseStocks spells out the per-broker (vs per-issuer) limitation. - README 'Revolut Stocks' notes call out the same limitation explicitly, next to the existing whtRate / gross-up explanation. Tests: new stocksFailsLoudlyOnNonUsTickerSuffix in RevolutParserTest asserts the exception type and message contents for a VOD.L row. mvn test: 56 run, 0 failures. --- README.md | 7 +++++ .../cz/solutions/cockroach/RevolutParser.kt | 29 ++++++++++++++++++- .../solutions/cockroach/RevolutParserTest.kt | 15 ++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6c1f908..17ee4dd 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,13 @@ Notes: treaty rate when a W-8BEN is on file, which Revolut signs for you automatically). The computed WHT is emitted as a tax credit so your CZ tax return matches what was actually withheld. If your treaty rate differs, override `revolut.whtRate` in the YAML. +- **`whtRate` is per-broker, not per-issuer.** The configured rate is applied uniformly to + every dividend row in every Revolut Stocks CSV. This is correct today because Revolut Stocks + lists US-domiciled shares only — should Revolut add non-US listings, or should you receive an + ADR whose underlying issuer-country withholds at a different rate, the gross-up will not + match what the broker actually withheld. The parser therefore **fails loudly** on any ticker + carrying a non-US exchange suffix (e.g. `.L`, `.DE`, `.PA`); split such rows out of the CSV + and report them manually, or extend `RevolutParser` with per-issuer routing. - `DIVIDEND TAX (CORRECTION)` rows always come in cancelling pairs (a debit and an immediate credit of the same magnitude); they are summed and ignored, with a log line confirming the net is zero. diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt index 544e9f7..90da4b0 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -53,6 +53,23 @@ object RevolutParser { // or a new Revolut row type. Fail loudly rather than silently dropping cash interest. private val SAVINGS_INTEREST_PATTERN = Regex("(?i)^Interest\\b") + // Tickers carrying a non-US exchange suffix such as ".L" (LSE), ".DE" (Xetra), ".PA" (Paris). + // parseStocks hard-codes country = "US" and applies the single per-broker whtRate uniformly, + // so any non-US ticker is a fail-loud signal: the gross-up would be wrong and the dividend + // would land on the wrong line of Příloha č. 3. + private val NON_US_TICKER_SUFFIX = Regex("""\.([A-Z]{1,3})\b""") + + /** + * Parses a Revolut Stocks CSV statement. + * + * `whtRate` is applied uniformly to **every** dividend row in the file: Revolut only reports + * net amounts and never the per-issuer tax actually withheld, so the gross-up + * `gross = net / (1 - whtRate)` assumes a single rate for the whole broker source. This is + * accurate today because Revolut Stocks lists US-domiciled shares only (15 % US/CZ treaty rate + * with W-8BEN), but if Revolut ever adds non-US listings — or if you receive an ADR whose + * issuer-country WHT differs — the per-row gross-up will be wrong. The parser therefore + * throws on any ticker carrying a non-US exchange suffix rather than silently mis-reporting. + */ fun parseStocks(file: File, whtRate: Double = DEFAULT_WHT_RATE): RevolutStocksParseResult { return file.reader(StandardCharsets.UTF_8).use { parseStocks(it, whtRate) } } @@ -80,7 +97,17 @@ object RevolutParser { val gross = amount / (1.0 - whtRate) val wht = gross - amount val ticker = record.get("Ticker").trim() - // Revolut Stocks supports US-listed shares only, so the ISIN prefix is always "US". + // Revolut Stocks supports US-listed shares only, so the ISIN prefix is always "US" + // and the per-broker whtRate is applied uniformly. Fail loudly on any non-US ticker: + // both the gross-up and the country attribution would be wrong otherwise. + val suffix = NON_US_TICKER_SUFFIX.find(ticker)?.groupValues?.get(1) + check(suffix == null) { + "Revolut Stocks: ticker '$ticker' at $date looks non-US (suffix .$suffix). " + + "parseStocks hard-codes country=US and grosses up at the single per-broker " + + "whtRate=${"%.4f".format(whtRate)}, which is wrong for non-US issuers. " + + "Split this row out and report it manually, or extend RevolutParser with " + + "per-issuer routing before re-running." + } dividends.add(DividendRecord(date, gross, currency, symbol = ticker, broker = BROKER_NAME, country = "US")) if (wht > 0.0) { taxes.add(TaxRecord(date, -wht, currency)) diff --git a/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt index 93c9a61..b5de444 100644 --- a/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt @@ -60,6 +60,20 @@ class RevolutParserTest { assertThat(result.taxRecords).hasSize(1) } + @Test + fun stocksFailsLoudlyOnNonUsTickerSuffix() { + val csv = """ + Date,Ticker,Type,Quantity,Price per share,Total Amount,Currency,FX Rate + 2025-03-15T09:00:00.000000Z,VOD.L,DIVIDEND,,,USD 1.70,USD,0.0420 + """.trimIndent() + + assertThatIllegalStateException() + .isThrownBy { RevolutParser.parseStocks(StringReader(csv)) } + .withMessageContaining("VOD.L") + .withMessageContaining("looks non-US") + .withMessageContaining("per-broker") + } + @Test fun parsesSavingsInterestAndIgnoresFeesAndBuySell() { val csv = """ @@ -121,4 +135,5 @@ class RevolutParserTest { assertThat(result.interestRecords[0].date).isEqualTo(LocalDate(2025, 12, 31)) assertThat(result.interestRecords[0].amount).isCloseTo(2.5, offset(0.0001)) } + } From 1d50ce90e8fe62c6de8a3bacb716557f1a74d486 Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 19:41:09 +0200 Subject: [PATCH 27/31] DividentReportPreparation: pair tax/dividend by (broker, symbol, date) explicitly Replace the 15%-of-amount heuristic that picked the closest tax row on the same date with an explicit composite key (broker, symbol, date) within the per-currency section. Multiple tax rows that share the same key (e.g. Schwab's same-day correction adjustments) are now summed before being attached to the dividend bucket, and multiple dividend rows sharing the same key collapse into a single printable line with summed amounts. Two new fail-loud invariants: - any dividend without a matching tax bucket throws IllegalStateException with broker/symbol/date/amount and a remediation hint; - any tax row without a matching dividend bucket throws IllegalStateException with the same diagnostic. To support keying, TaxRecord and TaxReversalRecord gain non-nullable symbol+broker fields; all production parsers (Degiro, eToro, Revolut, Morgan Stanley DividendXlsx, Schwab JsonExport) populate them from the underlying statement, and TestRecords.kt provides defaulted helpers so existing tests do not have to spell them out. Updated DividentReportPreparationTest with three scenarios covering the new key: two brokers paying the same symbol on the same day, multiple tax rows summed against one dividend, and an orphaned tax row failing loudly. mvn test: 58 run, 0 failures, 3 skipped. --- .../cockroach/DegiroAccountStatementParser.kt | 2 +- .../solutions/cockroach/DividendXlsxParser.kt | 2 +- .../cockroach/DividentReportPreparation.kt | 110 +++++++++++------- .../cz/solutions/cockroach/EtoroXlsxParser.kt | 2 +- .../solutions/cockroach/JsonExportParser.kt | 12 +- .../cz/solutions/cockroach/RevolutParser.kt | 2 +- .../cz/solutions/cockroach/TaxRecord.kt | 6 + .../solutions/cockroach/TaxReversalRecord.kt | 5 + .../DegiroAccountStatementParserTest.kt | 2 +- .../cockroach/DividendXlsxParserTest.kt | 4 +- .../DividentReportPreparationTest.kt | 65 +++++++++-- .../cockroach/EtoroXlsxParserTest.kt | 6 +- .../cockroach/JsonExportParserTest.kt | 8 +- .../cz/solutions/cockroach/TestRecords.kt | 20 +++- 14 files changed, 178 insertions(+), 68 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt index 597673a..32b6412 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt @@ -65,7 +65,7 @@ object DegiroAccountStatementParser { } DESC_TAX -> { val record = parseRecord(row) ?: continue - taxes.add(TaxRecord(record.date, record.amount, record.currency)) + taxes.add(TaxRecord(record.date, record.amount, record.currency, symbol = record.product, broker = BROKER_NAME)) } DESC_ADR_FEE -> { val record = parseRecord(row) ?: continue diff --git a/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt index 0e121f2..168288c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt @@ -46,7 +46,7 @@ object DividendXlsxParser { val symbol = description.replace(DESCRIPTION_TAIL, "").trim() if (description.contains("WITHHOLDING", ignoreCase = true)) { - taxes.add(TaxRecord(date, value, Currency.USD)) + taxes.add(TaxRecord(date, value, Currency.USD, symbol = symbol, broker = BROKER_NAME)) } else { dividends.add(DividendRecord(date, value, Currency.USD, symbol = symbol, broker = BROKER_NAME, country = "US")) } diff --git a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt index 453a908..4c1479d 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividentReportPreparation.kt @@ -1,12 +1,21 @@ package cz.solutions.cockroach +import org.joda.time.LocalDate import org.joda.time.format.DateTimeFormat import org.joda.time.format.DateTimeFormatter -import kotlin.math.abs object DividentReportPreparation { private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormat.forPattern("dd.MM.YYYY").withZoneUTC() + /** Pairing key for a dividend payment: same broker, same issuer, same pay date. + * Multiple tax rows sharing this key (e.g. Schwab adjustments) are summed before the + * pairing; multiple dividend rows sharing this key are aggregated into a single + * printable line. Currency is implied by the section being built. */ + private data class PayKey(val broker: String, val symbol: String, val date: LocalDate) : Comparable { + override fun compareTo(other: PayKey): Int = ORDER.compare(this, other) + companion object { private val ORDER = compareBy({ it.date }, { it.symbol }, { it.broker }) } + } + fun generateDividendReport( dividendRecordList: List, taxRecordList: List, @@ -70,35 +79,37 @@ object DividentReportPreparation { reversalRecords: List, exchangeRateProvider: ExchangeRateProvider ): CurrencyDividendSection { - val sortedDividends = dividendRecords.sortedBy { it.date } - val taxesByDate = taxRecords.groupBy({ it.date }) { it }.mapValues { it.value.toMutableList() } + val dividendsByKey = dividendRecords.groupBy { PayKey(it.broker, it.symbol, it.date) } + val taxesByKey = taxRecords.groupBy { PayKey(it.broker, it.symbol, it.date) } + + verifyAllTaxesMatched(taxesByKey, dividendsByKey, currency) + val printable = mutableListOf() var totalBrutto = 0.0 var totalTax = 0.0 var totalBruttoCrown = 0.0 var totalTaxCrown = 0.0 - for (dividendRecord in sortedDividends) { - val exchange = exchangeRateProvider.rateAt(dividendRecord.date, currency) - val taxCandidates = taxesByDate[dividendRecord.date] - val taxRecord = taxCandidates?.minByOrNull { abs(abs(it.amount) - abs(dividendRecord.amount) * 0.15) } //if there were more taxes on the same day, we take the one closest to 15% of dividend amount, because that's the most likely correct one - ?: error(missingTaxMessage(dividendRecord, currency)) - taxCandidates.remove(taxRecord) - totalBrutto += dividendRecord.amount - totalTax += taxRecord.amount - totalBruttoCrown += dividendRecord.amount * exchange - totalTaxCrown += taxRecord.amount * exchange + for ((key, dividendRecords) in dividendsByKey.toSortedMap()) { + val taxes = taxesByKey[key] ?: error(missingTaxMessage(key, dividendRecords, currency)) + val exchange = exchangeRateProvider.rateAt(key.date, currency) + val divAmount = dividendRecords.sumOf { it.amount } + val taxAmount = taxes.sumOf { it.amount } + totalBrutto += divAmount + totalTax += taxAmount + totalBruttoCrown += divAmount * exchange + totalTaxCrown += taxAmount * exchange printable.add( PrintableDividend( - dividendRecord.symbol, - dividendRecord.broker, - DATE_FORMATTER.print(dividendRecord.date), - FormatingHelper.formatDouble(dividendRecord.amount), + key.symbol, + key.broker, + DATE_FORMATTER.print(key.date), + FormatingHelper.formatDouble(divAmount), FormatingHelper.formatExchangeRate(exchange), - FormatingHelper.formatDouble(taxRecord.amount), - FormatingHelper.formatDouble(exchange * dividendRecord.amount), - FormatingHelper.formatDouble(exchange * taxRecord.amount) + FormatingHelper.formatDouble(taxAmount), + FormatingHelper.formatDouble(exchange * divAmount), + FormatingHelper.formatDouble(exchange * taxAmount) ) ) } @@ -114,26 +125,28 @@ object DividentReportPreparation { taxRecords: List, reversalRecords: List ): CzkDividendSection { - val sortedDividends = dividendRecords.sortedBy { it.date } - val taxesByDate = taxRecords.groupBy({ it.date }) { it }.mapValues { it.value.toMutableList() } + val dividendsByKey = dividendRecords.groupBy { PayKey(it.broker, it.symbol, it.date) } + val taxesByKey = taxRecords.groupBy { PayKey(it.broker, it.symbol, it.date) } + + verifyAllTaxesMatched(taxesByKey, dividendsByKey, Currency.CZK) + val printable = mutableListOf() var totalBruttoCrown = 0.0 var totalTaxCrown = 0.0 - for (dividendRecord in sortedDividends) { - val taxCandidates = taxesByDate[dividendRecord.date] - val taxRecord = taxCandidates?.minByOrNull { abs(abs(it.amount) - abs(dividendRecord.amount) * 0.15) } - ?: error(missingTaxMessage(dividendRecord, Currency.CZK)) - taxCandidates.remove(taxRecord) - totalBruttoCrown += dividendRecord.amount - totalTaxCrown += taxRecord.amount + for ((key, divs) in dividendsByKey.toSortedMap()) { + val taxes = taxesByKey[key] ?: error(missingTaxMessage(key, divs, Currency.CZK)) + val divAmount = divs.sumOf { it.amount } + val taxAmount = taxes.sumOf { it.amount } + totalBruttoCrown += divAmount + totalTaxCrown += taxAmount printable.add( PrintableCzkDividend( - dividendRecord.symbol, - dividendRecord.broker, - DATE_FORMATTER.print(dividendRecord.date), - FormatingHelper.formatDouble(dividendRecord.amount), - FormatingHelper.formatDouble(taxRecord.amount) + key.symbol, + key.broker, + DATE_FORMATTER.print(key.date), + FormatingHelper.formatDouble(divAmount), + FormatingHelper.formatDouble(taxAmount) ) ) } @@ -142,12 +155,29 @@ object DividentReportPreparation { return CzkDividendSection(printable, totalBruttoCrown, totalTaxCrown, totalTaxReversalCrown) } - private fun missingTaxMessage(dividend: DividendRecord, currency: Currency): String { - val symbol = dividend.symbol - val broker = dividend.broker - return "No matching tax record found for dividend on ${DATE_FORMATTER.print(dividend.date)} " + - "(broker=$broker, symbol=$symbol, amount=${FormatingHelper.formatDouble(dividend.amount)} ${currency.name}). " + + private fun verifyAllTaxesMatched( + taxesByKey: Map>, + dividendsByKey: Map>, + currency: Currency, + ) { + val orphaned = taxesByKey.keys - dividendsByKey.keys + check(orphaned.isEmpty()) { + val sample = orphaned.min() + val totalAmount = taxesByKey.getValue(sample).sumOf { it.amount } + "Tax record without matching dividend in ${currency.name} section " + + "(broker=${sample.broker}, symbol=${sample.symbol}, date=${DATE_FORMATTER.print(sample.date)}, " + + "amount=${FormatingHelper.formatDouble(totalAmount)}). " + + "${orphaned.size - 1} other unmatched tax key(s). " + + "Verify the broker statement: every withholding row must be paired with a dividend row " + + "carrying the same broker/symbol/date — fix the parser or the input data." + } + } + + private fun missingTaxMessage(key: PayKey, dividendRecords: List, currency: Currency): String { + val divAmount = dividendRecords.sumOf { it.amount } + return "No matching tax record found for dividend on ${DATE_FORMATTER.print(key.date)} " + + "(broker=${key.broker}, symbol=${key.symbol}, amount=${FormatingHelper.formatDouble(divAmount)} ${currency.name}). " + "If withholding tax is genuinely 0%, add an explicit TaxRecord with amount=0.0 on the same date in the parser; " + - "otherwise verify that the broker statement contains the corresponding tax row and that its date matches the dividend date." + "otherwise verify that the broker statement contains the corresponding tax row and that its broker/symbol/date match the dividend." } } \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt index 4500cf6..e138634 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt @@ -101,7 +101,7 @@ object EtoroXlsxParser { } dividends.add(DividendRecord(date, gross, Currency.USD, symbol = instrument, broker = BROKER_NAME, country = "US")) if (wht > 0.0) { - taxes.add(TaxRecord(date, -wht, Currency.USD)) + taxes.add(TaxRecord(date, -wht, Currency.USD, symbol = instrument, broker = BROKER_NAME)) } } LOGGER.info("eToro: parsed ${dividends.size} dividend(s) and ${taxes.size} withholding tax record(s) from $fileName") diff --git a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt index b68ba13..b8109ad 100644 --- a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt @@ -75,14 +75,18 @@ class JsonExportParser { TaxRecord( it.date, it.amount, - Currency.USD + Currency.USD, + symbol = it.symbol, + broker = BROKER, ) }, export.transactions.filterIsInstance().map { TaxReversalRecord( it.date, it.amount, - Currency.USD + Currency.USD, + symbol = it.symbol, + broker = BROKER, ) }, @@ -208,6 +212,8 @@ sealed class Transaction { @Contextual val amount: Double, + + val symbol: String, ) : Transaction() @Serializable @@ -217,6 +223,8 @@ sealed class Transaction { @Contextual val amount: Double, + + val symbol: String, ) : Transaction() @Serializable diff --git a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt index 90da4b0..d6c534b 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -110,7 +110,7 @@ object RevolutParser { } dividends.add(DividendRecord(date, gross, currency, symbol = ticker, broker = BROKER_NAME, country = "US")) if (wht > 0.0) { - taxes.add(TaxRecord(date, -wht, currency)) + taxes.add(TaxRecord(date, -wht, currency, symbol = ticker, broker = BROKER_NAME)) } } STOCKS_TYPE_DIVIDEND_TAX_CORRECTION -> { diff --git a/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt b/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt index 2905ebf..b4922d0 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt @@ -6,4 +6,10 @@ data class TaxRecord( val date: LocalDate, val amount: Double, val currency: Currency, + /** Issuer symbol of the underlying dividend, used to pair the tax row with its DividendRecord + * in [DividentReportPreparation]. Must match the dividend's symbol exactly. */ + val symbol: String, + /** Broker that produced both the dividend and this tax row; part of the pairing key so two + * brokers paying the same symbol on the same date cannot cross-match. */ + val broker: String, ) \ No newline at end of file diff --git a/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt b/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt index 659c5e6..c66e7e7 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt @@ -6,4 +6,9 @@ data class TaxReversalRecord( val date: LocalDate, val amount: Double, val currency: Currency, + /** Issuer symbol the original withholding belonged to. Carried for reporting parity with + * [TaxRecord]; reversals are aggregated per currency rather than paired to a single dividend. */ + val symbol: String, + /** Broker that produced the original withholding and this reversal. */ + val broker: String, ) \ No newline at end of file diff --git a/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt index 8841b94..7d3ade9 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DegiroAccountStatementParserTest.kt @@ -43,7 +43,7 @@ class DegiroAccountStatementParserTest { DividendRecord(LocalDate(2024, 3, 15), 12.34, Currency.USD, symbol = "APPLE INC", broker = "Degiro", country = "US") ) assertThat(result.taxRecords).containsExactly( - TaxRecord(LocalDate(2024, 3, 15), -1.85, Currency.USD) + TaxRecord(LocalDate(2024, 3, 15), -1.85, Currency.USD, symbol = "APPLE INC", broker = "Degiro") ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt index 8e50516..24f5d61 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt @@ -23,7 +23,7 @@ class DividendXlsxParserTest { DividendRecord(LocalDate(2025, 10, 22), 58.22, Currency.USD, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.", country = "US") ) assertThat(result.taxRecords).containsExactly( - TaxRecord(LocalDate(2025, 10, 22), -8.73, Currency.USD) + TaxRecord(LocalDate(2025, 10, 22), -8.73, Currency.USD, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.") ) } @@ -53,7 +53,7 @@ class DividendXlsxParserTest { DividendRecord(LocalDate(2025, 10, 22), 58.22, Currency.USD, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.", country = "US") ) assertThat(result.taxRecords).containsExactly( - TaxRecord(LocalDate(2025, 10, 22), -8.73, Currency.USD) + TaxRecord(LocalDate(2025, 10, 22), -8.73, Currency.USD, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.") ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt index a817489..1f3d0fe 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt @@ -12,17 +12,17 @@ class DividentReportPreparationTest { private val year2025 = DateInterval.year(2025) @Test - fun taxRecordIsNotReusedWhenMultipleDividendsOnSameDate() { - // Schwab: large dividend with 30% withholding - // E-Trade: small dividend with 15% withholding - // Both on the same date - simulates the real bug + fun taxesFromDifferentBrokersOnSameDateAreMatchedSeparately() { + // Schwab CSCO and E-Trade CSCO can pay on the same date with different withholding rates. + // Each (broker, symbol, date) bucket must be paired independently — the old 15% heuristic + // used to attach the wrong tax row to the wrong dividend in this scenario. val dividends = listOf( - dividendRecord(LocalDate(2025, 10, 22), 1426.80), // Schwab - dividendRecord(LocalDate(2025, 10, 22), 59.04) // E-Trade + dividendRecord(LocalDate(2025, 10, 22), 1426.80, symbol = "CSCO", broker = "Schwab"), + dividendRecord(LocalDate(2025, 10, 22), 59.04, symbol = "CSCO", broker = "Morgan Stanley & Co.") ) val taxes = listOf( - taxRecord(LocalDate(2025, 10, 22), -428.04), // Schwab (30% of 1426.80) - taxRecord(LocalDate(2025, 10, 22), -8.86) // E-Trade (15% of 59.04) + taxRecord(LocalDate(2025, 10, 22), -428.04, symbol = "CSCO", broker = "Schwab"), + taxRecord(LocalDate(2025, 10, 22), -8.86, symbol = "CSCO", broker = "Morgan Stanley & Co.") ) val report = DividentReportPreparation.generateDividendReport( @@ -31,16 +31,57 @@ class DividentReportPreparationTest { val usd = report.sections.single { it.currency == Currency.USD } - // Both dividends should be matched + // One row per (broker, symbol, date) bucket. assertThat(usd.printableDividendList).hasSize(2) - // The total tax should include BOTH tax records, not -8.86 twice + // The total tax should include BOTH tax records, not -8.86 twice (the old heuristic bug). assertThat(usd.totalTax).isCloseTo(-436.90, Offset.offset(0.01)) - - // Each tax should be used exactly once assertThat(usd.totalTaxCrown).isCloseTo(-436.90 * 25.0, Offset.offset(0.1)) } + @Test + fun multipleTaxRowsForSameDividendAreSummed() { + // Schwab sometimes emits the gross withholding plus a same-day correction. Both rows share + // (broker, symbol, date) and must be summed into a single net tax against the dividend. + val dividends = listOf( + dividendRecord(LocalDate(2025, 6, 27), 100.0, symbol = "JNJ", broker = "Schwab") + ) + val taxes = listOf( + taxRecord(LocalDate(2025, 6, 27), -30.0, symbol = "JNJ", broker = "Schwab"), + taxRecord(LocalDate(2025, 6, 27), 15.0, symbol = "JNJ", broker = "Schwab"), // partial reversal on same day + ) + + val report = DividentReportPreparation.generateDividendReport( + dividends, taxes, emptyList(), year2025, fixedRate + ) + + val usd = report.sections.single { it.currency == Currency.USD } + assertThat(usd.printableDividendList).hasSize(1) + assertThat(usd.totalTax).isCloseTo(-15.0, Offset.offset(0.01)) + } + + @Test + fun orphanedTaxWithoutMatchingDividendFailsLoudly() { + val dividends = listOf( + dividendRecord(LocalDate(2025, 3, 10), 100.0, symbol = "AAPL", broker = "Schwab") + ) + val taxes = listOf( + taxRecord(LocalDate(2025, 3, 10), -15.0, symbol = "AAPL", broker = "Schwab"), + // Orphan: no matching dividend with this symbol. + taxRecord(LocalDate(2025, 3, 10), -5.0, symbol = "MSFT", broker = "Schwab"), + ) + + assertThatThrownBy { + DividentReportPreparation.generateDividendReport( + dividends, taxes, emptyList(), year2025, fixedRate + ) + } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("Tax record without matching dividend") + .hasMessageContaining("symbol=MSFT") + .hasMessageContaining("broker=Schwab") + } + @Test fun singleDividendWithSingleTaxOnSameDate() { val dividends = listOf(dividendRecord(LocalDate(2025, 1, 22), 1000.0)) diff --git a/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt index c0af409..bfd9c8e 100644 --- a/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt @@ -35,8 +35,8 @@ class EtoroXlsxParserTest { DividendRecord(LocalDate(2025, 6, 15), 5.00, Currency.USD, symbol = "VOD.L", broker = "eToro", country = "US"), ) assertThat(result.taxRecords).containsExactly( - TaxRecord(LocalDate(2025, 2, 1), -0.15, Currency.USD), - TaxRecord(LocalDate(2025, 6, 15), -0.50, Currency.USD), + TaxRecord(LocalDate(2025, 2, 1), -0.15, Currency.USD, symbol = "AAPL", broker = "eToro"), + TaxRecord(LocalDate(2025, 6, 15), -0.50, Currency.USD, symbol = "VOD.L", broker = "eToro"), ) } @@ -78,7 +78,7 @@ class EtoroXlsxParserTest { DividendRecord(LocalDate(2025, 1, 2), 1.00, Currency.USD, symbol = "AAPL", broker = "eToro", country = "US") ) assertThat(result.taxRecords).containsExactly( - TaxRecord(LocalDate(2025, 1, 2), -0.15, Currency.USD) + TaxRecord(LocalDate(2025, 1, 2), -0.15, Currency.USD, symbol = "AAPL", broker = "eToro") ) } diff --git a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt index 7cdd274..917ca31 100644 --- a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt @@ -54,14 +54,18 @@ class JsonExportParserTest { TaxRecord( LocalDate(2023,10,25), -12.66, - Currency.USD + Currency.USD, + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ), listOf( TaxReversalRecord( LocalDate(2023,2,7), 1.88, - Currency.USD + Currency.USD, + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ), listOf( diff --git a/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt index 43c58ad..8aa1061 100644 --- a/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt +++ b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt @@ -46,13 +46,29 @@ fun taxRecord( date: LocalDate, amount: Double, currency: Currency = Currency.USD, -): TaxRecord = TaxRecord(date = date, amount = amount, currency = currency) + symbol: String = "TEST", + broker: String = "TestBroker", +): TaxRecord = TaxRecord( + date = date, + amount = amount, + currency = currency, + symbol = symbol, + broker = broker, +) fun taxReversalRecord( date: LocalDate, amount: Double, currency: Currency = Currency.USD, -): TaxReversalRecord = TaxReversalRecord(date = date, amount = amount, currency = currency) + symbol: String = "TEST", + broker: String = "TestBroker", +): TaxReversalRecord = TaxReversalRecord( + date = date, + amount = amount, + currency = currency, + symbol = symbol, + broker = broker, +) fun saleRecord( date: LocalDate, From 029fb85493ee808c77288f4c86fdef65ee50ce81 Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 19:50:40 +0200 Subject: [PATCH 28/31] CNB rates: prefer bundled classpath snapshot over HTTP Past tax years remain reproducible offline and survive cnb.cz outages once their year.txt is shipped under src/main/resources/cz/solutions/cockroach/rates_.txt. * ClasspathCnbYearRatesSource: expose hasYear(year). * New ClasspathOrHttpCnbYearRatesSource: prefers bundled snapshot when available; only consults HTTP for years not on the classpath (typically the current/future year). * CockroachMain.report wraps HttpCnbYearRatesSource with the composite. * README: documents the lookup order and how to pin a new year. * CnbYearRatesSourceTest covers hasYear, bundled-preferred, HTTP delegation, and propagated HTTP failure. mvn test: 62 run, 0 failures, 3 skipped. --- README.md | 20 ++++++ .../solutions/cockroach/CnbYearRatesSource.kt | 46 +++++++++++--- .../cz/solutions/cockroach/CockroachMain.kt | 2 +- .../cockroach/CnbYearRatesSourceTest.kt | 62 +++++++++++++++++++ 4 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/cz/solutions/cockroach/CnbYearRatesSourceTest.kt diff --git a/README.md b/README.md index 17ee4dd..3aa4860 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,26 @@ At least one of `schwab`, `etrade`, `degiro`, `revolut.stocks`, `revolut.savings export options under Tools \> Markdown Converter menu.\ ![](media/image2.png) +# CNB exchange rates + +For each requested year the dynamic-rate variant resolves the daily CNB +fixing for every transaction date. Sources are tried in this order: + +1. **Bundled snapshot** — for years shipped with the release a copy of the + year's `year.txt` is included on the classpath + (`src/main/resources/cz/solutions/cockroach/rates_.txt`). Past + years are reproducible offline and survive CNB website outages. + +2. **HTTP download** — for any year not bundled (typically the current or + future year) `https://www.cnb.cz/.../year.txt` is fetched and cached + under `~/.cache/cockroach/rates/`. Caching is permanent only once the + year is at least 30 days complete; the still-running current year is + re-fetched on every run. + +To pin a new completed year for offline use, drop the downloaded +`year.txt` into `src/main/resources/cz/solutions/cockroach/rates_.txt` +and rebuild. + # Compiling and Running mvn clean install -am diff --git a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt index e53480e..f634d2c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt @@ -21,15 +21,22 @@ interface CnbYearRatesSource { /** * Reads CNB year.txt content from bundled classpath resources. * - * Used by tests and by [TabularExchangeRateProvider.hardcoded] so that no - * network is touched during unit tests. + * Bundled snapshots are authoritative for completed years (CNB never amends + * past fixings) and let the tool run offline / reproducibly for those years. + * Used by tests, by [TabularExchangeRateProvider.hardcoded] and as the + * preferred branch of [ClasspathOrHttpCnbYearRatesSource]. */ class ClasspathCnbYearRatesSource : CnbYearRatesSource { - override fun loadYear(year: Int): List { - return when (year) { - 2022 -> listOf(loadResource("rates_2022_a.txt"), loadResource("rates_2022_b.txt")) - else -> listOf(loadResource("rates_$year.txt")) - } + override fun loadYear(year: Int): List = resourceNames(year).map { loadResource(it) } + + /** True iff every resource needed for [year] is present on the classpath. */ + fun hasYear(year: Int): Boolean = resourceNames(year).all { + ClasspathCnbYearRatesSource::class.java.getResource(it) != null + } + + private fun resourceNames(year: Int): List = when (year) { + 2022 -> listOf("rates_2022_a.txt", "rates_2022_b.txt") + else -> listOf("rates_$year.txt") } private fun loadResource(name: String): String { @@ -39,6 +46,31 @@ class ClasspathCnbYearRatesSource : CnbYearRatesSource { } } +/** + * Prefers a classpath-bundled snapshot for years that ship with the + * release (offline, reproducible) and only consults [http] for years not + * bundled — typically the current/future year, or anything past the most + * recent release. This means a report for a completed past year does not + * require network access and survives CNB website outages. + */ +class ClasspathOrHttpCnbYearRatesSource( + private val http: CnbYearRatesSource, + private val classpath: ClasspathCnbYearRatesSource = ClasspathCnbYearRatesSource(), +) : CnbYearRatesSource { + + override fun loadYear(year: Int): List { + if (classpath.hasYear(year)) { + LOGGER.fine("using bundled CNB rates for $year") + return classpath.loadYear(year) + } + return http.loadYear(year) + } + + companion object { + private val LOGGER = Logger.getLogger(ClasspathOrHttpCnbYearRatesSource::class.java.name) + } +} + /** * Downloads CNB year.txt from cnb.cz on first use and caches completed * past years on disk under [cacheDir]. CNB never amends fixings diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index 681dff6..7336b0d 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -89,7 +89,7 @@ object CockroachMain { .map { it.parse() } .fold(ParsedExport.empty()) { acc, e -> acc + e } val dailyRateProvider = TabularExchangeRateProvider.fromSource( - HttpCnbYearRatesSource(HttpCnbYearRatesSource.defaultCacheDir()), + ClasspathOrHttpCnbYearRatesSource(HttpCnbYearRatesSource(HttpCnbYearRatesSource.defaultCacheDir())), (year - 1)..year ) val fixedRateReport = ReportGenerator.generateForYear(parsedExport, year, YearConstantExchangeRateProvider.hardcoded()) diff --git a/src/test/kotlin/cz/solutions/cockroach/CnbYearRatesSourceTest.kt b/src/test/kotlin/cz/solutions/cockroach/CnbYearRatesSourceTest.kt new file mode 100644 index 0000000..80e4043 --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/CnbYearRatesSourceTest.kt @@ -0,0 +1,62 @@ +package cz.solutions.cockroach + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.contains +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +internal class CnbYearRatesSourceTest { + + private val classpath = ClasspathCnbYearRatesSource() + + @Test + fun `classpath source reports bundled years`() { + assertThat(classpath.hasYear(2021), `is`(true)) + assertThat(classpath.hasYear(2022), `is`(true)) + assertThat(classpath.hasYear(2025), `is`(true)) + assertThat(classpath.hasYear(1999), `is`(false)) + assertThat(classpath.hasYear(2099), `is`(false)) + } + + @Test + fun `composite prefers bundled snapshot for completed year`() { + val http = RecordingSource() + val composite = ClasspathOrHttpCnbYearRatesSource(http = http) + + val chunks = composite.loadYear(2025) + + assertThat(chunks.size >= 1, `is`(true)) + assertThat(http.calls, `is`(emptyList())) + } + + @Test + fun `composite delegates to http for years not bundled`() { + val http = RecordingSource(response = listOf("Date|1 USD\n01.01.2099|25.000")) + val composite = ClasspathOrHttpCnbYearRatesSource(http = http) + + val chunks = composite.loadYear(2099) + + assertThat(chunks, contains("Date|1 USD\n01.01.2099|25.000")) + assertThat(http.calls, contains(2099)) + } + + @Test + fun `composite propagates http failure for years not bundled`() { + val composite = ClasspathOrHttpCnbYearRatesSource(http = FailingSource()) + + assertThrows(IllegalStateException::class.java) { composite.loadYear(2099) } + } + + private class RecordingSource(private val response: List = listOf("stub")) : CnbYearRatesSource { + val calls = mutableListOf() + override fun loadYear(year: Int): List { + calls.add(year) + return response + } + } + + private class FailingSource : CnbYearRatesSource { + override fun loadYear(year: Int): List = throw IllegalStateException("network down") + } +} From ceb1d475d00abf57cb85ea9d0c9fb35d2d0c5d76 Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 19:53:25 +0200 Subject: [PATCH 29/31] CockroachMain: split runCockroach into form-specific builders Pulls YAML and positional CLI source-list construction out of the top-level dispatcher into invocationFromYaml() and invocationFromPositionalArgs(), each returning a small private Invocation(year, outputDir, sources). runCockroach now only picks the form, parses, and calls CockroachMain.report. No behavior change. mvn test: 62 run, 0 failures, 3 skipped. --- .../cz/solutions/cockroach/CockroachMain.kt | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt index 7336b0d..e1690a0 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -13,40 +13,53 @@ fun main(args: Array) { } } +private data class Invocation(val year: Int, val outputDir: File, val sources: List) + private fun runCockroach(args: Array) { - if (args.size == 1 && (args[0].endsWith(".yaml") || args[0].endsWith(".yml"))) { - val config = CockroachConfig.load(File(args[0])) - val sources = buildList { - config.schwab?.let { add(SchwabBrokerSource(File(it))) } - if (config.etrade != null || config.etradeBenefitHistory != null) { - add(ETradeBrokerSource( - directory = config.etrade?.let { File(it) }, - benefitHistoryFile = config.etradeBenefitHistory?.let { File(it) }, - )) - } - if (config.degiro.isNotEmpty()) add(DegiroBrokerSource(config.degiro.map { File(it) })) - if (config.revolut.stocks.isNotEmpty() || config.revolut.savings.isNotEmpty()) { - add(RevolutBrokerSource( - stocksFiles = config.revolut.stocks.map { File(it) }, - savingsFiles = config.revolut.savings.map { File(it) }, - whtRate = config.revolut.whtRate, - )) - } - if (config.etoro.isNotEmpty()) add(EtoroBrokerSource(config.etoro.map { File(it) })) - if (config.vub.isNotEmpty()) add(VubBrokerSource(config.vub.map { File(it) }, year = config.year)) - } - CockroachMain.report(config.year, File(config.outputDir), sources) - return - } - if (args.size < 3) { + val invocation = parseInvocation(args) ?: run { printUsage() exitProcess(1) } + CockroachMain.report(invocation.year, invocation.outputDir, invocation.sources) +} + +private fun parseInvocation(args: Array): Invocation? = when { + args.size == 1 && (args[0].endsWith(".yaml") || args[0].endsWith(".yml")) -> + invocationFromYaml(File(args[0])) + args.size >= 3 -> invocationFromPositionalArgs(args) + else -> null +} + +private fun invocationFromYaml(yamlFile: File): Invocation { + val config = CockroachConfig.load(yamlFile) + val sources = buildList { + config.schwab?.let { add(SchwabBrokerSource(File(it))) } + if (config.etrade != null || config.etradeBenefitHistory != null) { + add(ETradeBrokerSource( + directory = config.etrade?.let { File(it) }, + benefitHistoryFile = config.etradeBenefitHistory?.let { File(it) }, + )) + } + if (config.degiro.isNotEmpty()) add(DegiroBrokerSource(config.degiro.map { File(it) })) + if (config.revolut.stocks.isNotEmpty() || config.revolut.savings.isNotEmpty()) { + add(RevolutBrokerSource( + stocksFiles = config.revolut.stocks.map { File(it) }, + savingsFiles = config.revolut.savings.map { File(it) }, + whtRate = config.revolut.whtRate, + )) + } + if (config.etoro.isNotEmpty()) add(EtoroBrokerSource(config.etoro.map { File(it) })) + if (config.vub.isNotEmpty()) add(VubBrokerSource(config.vub.map { File(it) }, year = config.year)) + } + return Invocation(config.year, File(config.outputDir), sources) +} + +private fun invocationFromPositionalArgs(args: Array): Invocation { val sources = buildList { add(SchwabBrokerSource(File(args[0]))) if (args.size > 3) add(ETradeBrokerSource(directory = File(args[3]))) } - CockroachMain.report(args[1].toInt(), File(args[2]), sources) + return Invocation(args[1].toInt(), File(args[2]), sources) } private fun printUsage() { From 049a41a3bb56f098e40bcd675af7060ec9f1881c Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 19:59:27 +0200 Subject: [PATCH 30/31] ETradeBenefitHistoryParserTest: anonymized BenefitHistory.xlsx fixture Replaces the assumeTrue dependency on a private input/BenefitHistory.xlsx with a checked-in synthetic fixture under src/test/resources/cz/solutions/cockroach/. The fixture is built by BenefitHistoryFixtureGenerator, which mirrors the structural layout of a real Morgan Stanley Stock Plan Connect export (sheet names, headers including the duplicated 'Vested Qty.' column, and the Grant/Vest Schedule/Tax Withholding row sequence) but uses anonymised symbol (ACME), grant ids (G-1001, G-1002, G-1003), quantities, and amounts. G-1003 has zero vested shares and must be excluded by the parser, exercising the same path the production file does. mvn test: 62 run, 0 failures, 1 skipped (was 3 skipped). --- .../BenefitHistoryFixtureGenerator.kt | 132 ++++++++++++++++++ .../ETradeBenefitHistoryParserTest.kt | 68 +++++---- .../solutions/cockroach/BenefitHistory.xlsx | Bin 0 -> 5275 bytes 3 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 src/test/kotlin/cz/solutions/cockroach/BenefitHistoryFixtureGenerator.kt create mode 100644 src/test/resources/cz/solutions/cockroach/BenefitHistory.xlsx diff --git a/src/test/kotlin/cz/solutions/cockroach/BenefitHistoryFixtureGenerator.kt b/src/test/kotlin/cz/solutions/cockroach/BenefitHistoryFixtureGenerator.kt new file mode 100644 index 0000000..334f1b5 --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/BenefitHistoryFixtureGenerator.kt @@ -0,0 +1,132 @@ +package cz.solutions.cockroach + +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.Sheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import java.io.OutputStream + +/** + * Builds a synthetic E-Trade BenefitHistory.xlsx fixture that mirrors the column layout of a real + * export from the Morgan Stanley Stock Plan Connect portal (sheet names, header rows, the + * Grant/Vest Schedule/Tax Withholding row sequence, and the duplicated "Vested Qty." header that + * [ETradeBenefitHistoryParser] relies on). Symbols, grant ids, quantities, and amounts are all + * anonymised; only the structural layout is preserved. + */ +object BenefitHistoryFixtureGenerator { + + private val ESPP_HEADER = listOf( + "Record Type", "Symbol", "Purchase Date", "Purchase Price", "Purchased Qty.", + "Tax Collection Shares", "Net Shares", "Sellable Qty.", "Est. Market Value", + "Grant Date", "Discount Percent", "Grant Date FMV", "Purchase Date FMV", + "Qualified Plan?", "Contribution Source", "Pending Sale Qty.", "Blocked Qty.", + "Transferable Date", "First Sellable Date", "Date", "Event Type", "Qty" + ) + + private val RSU_HEADER = listOf( + "Record Type", "Symbol", "Grant Date", "Settlement Type", "Granted Qty.", + "Withheld Qty.", "Vested Qty.", "Deferred / Pending Release Qty.", "Sellable Qty.", + "Est. Market Value", "Grant Number", "Achieved Qty.", "Unvested Qty.", "Type", + "Unreleased Dividend Value", "Award Price", "Class", "Status", "Pending Sale Qty.", + "Blocked Qty.", "Cancelled Qty.", "Date", "Event Type", "Qty. or Amount", + "Vest Period", "Vest Date", "Deferred Until", "Granted Qty.", "Achieved Qty.", + "Reason for cancelled qty", "Cancelled Qty.", "Date Cancelled", "Vested Qty.", + "Released Qty", "Released Amount", "Sellable Qty.", "Blocked Qty.", + "Total Taxes Paid", "Tax Description", "Taxable Gain", "Effective Tax Rate", + "Withholding Amount" + ) + + private const val SYMBOL = "ACME" + + fun write(out: OutputStream) { + XSSFWorkbook().use { wb -> + writeEsppSheet(wb.createSheet("ESPP")) + writeRsuSheet(wb.createSheet("Restricted Stock")) + wb.write(out) + } + } + + private fun writeEsppSheet(sheet: Sheet) { + writeHeader(sheet, ESPP_HEADER) + // Three purchases: one in 2025-Q1, one in 2025-Q3, one in 2026-Q1. Each followed by an Event row. + var r = 1 + r = esppPurchase(sheet, r, date = "15-MAR-2025", purchaseFmv = 15.00) + r = esppEvent(sheet, r, date = "03/15/2025") + r = esppPurchase(sheet, r, date = "15-SEP-2025", purchaseFmv = 20.00) + r = esppEvent(sheet, r, date = "09/15/2025") + r = esppPurchase(sheet, r, date = "15-MAR-2026", purchaseFmv = 18.00) + r = esppEvent(sheet, r, date = "03/15/2026") + // Totals row (parser ignores it; included for fidelity). + sheet.createRow(r).also { setText(it, 0, "Totals"); setNum(it, 4, 300.0) } + } + + private fun esppPurchase(sheet: Sheet, rowIdx: Int, date: String, purchaseFmv: Double): Int { + val row = sheet.createRow(rowIdx) + setText(row, 0, "Purchase"); setText(row, 1, SYMBOL); setText(row, 2, date) + setNum(row, 3, 10.00); setNum(row, 4, 100.0); setNum(row, 11, 12.00); setNum(row, 12, purchaseFmv) + return rowIdx + 1 + } + + private fun esppEvent(sheet: Sheet, rowIdx: Int, date: String): Int { + val row = sheet.createRow(rowIdx) + setText(row, 0, "Event"); setText(row, 19, date); setText(row, 20, "PURCHASE"); setNum(row, 21, 100.0) + return rowIdx + 1 + } + + private fun writeRsuSheet(sheet: Sheet) { + writeHeader(sheet, RSU_HEADER) + var r = 1 + // Grant G-1001 - small grant, four vested tranches across 2025-2026. + r = rsuGrant(sheet, r, "G-1001") + r = rsuVestPair(sheet, r, "G-1001", period = 1, vestDate = "06/20/2025", qty = 50, taxableGain = 750.00) + r = rsuVestPair(sheet, r, "G-1001", period = 2, vestDate = "09/20/2025", qty = 50, taxableGain = 1000.00) + r = rsuVestPair(sheet, r, "G-1001", period = 3, vestDate = "12/20/2025", qty = 50, taxableGain = 1100.00) + r = rsuVestPair(sheet, r, "G-1001", period = 4, vestDate = "03/20/2026", qty = 50, taxableGain = 1250.00) + // Grant G-1002 - larger grant, four vested tranches. + r = rsuGrant(sheet, r, "G-1002") + r = rsuVestPair(sheet, r, "G-1002", period = 1, vestDate = "06/20/2025", qty = 200, taxableGain = 3000.00) + r = rsuVestPair(sheet, r, "G-1002", period = 2, vestDate = "09/20/2025", qty = 200, taxableGain = 4000.00) + r = rsuVestPair(sheet, r, "G-1002", period = 3, vestDate = "12/20/2025", qty = 200, taxableGain = 4400.00) + r = rsuVestPair(sheet, r, "G-1002", period = 4, vestDate = "03/20/2026", qty = 200, taxableGain = 5000.00) + // Grant G-1003 - future grant with no vested shares (parser must skip it entirely). + r = rsuGrant(sheet, r, "G-1003") + r = rsuVestScheduleRow(sheet, r, "G-1003", period = 1, vestDate = "06/20/2027", qty = 0) + r = rsuVestScheduleRow(sheet, r, "G-1003", period = 2, vestDate = "09/20/2027", qty = 0) + sheet.createRow(r).also { setText(it, 0, "Totals") } + } + + private fun rsuGrant(sheet: Sheet, rowIdx: Int, grantId: String): Int { + val row = sheet.createRow(rowIdx) + setText(row, 0, "Grant"); setText(row, 1, SYMBOL); setText(row, 10, grantId) + return rowIdx + 1 + } + + private fun rsuVestPair(sheet: Sheet, rowIdx: Int, grantId: String, period: Int, vestDate: String, qty: Int, taxableGain: Double): Int { + val schedule = sheet.createRow(rowIdx) + setText(schedule, 0, "Vest Schedule"); setText(schedule, 10, grantId) + setNum(schedule, 24, period.toDouble()); setText(schedule, 25, vestDate); setNum(schedule, 32, qty.toDouble()) + val tax = sheet.createRow(rowIdx + 1) + setText(tax, 0, "Tax Withholding"); setText(tax, 10, grantId) + setNum(tax, 24, period.toDouble()); setText(tax, 38, "Czech"); setNum(tax, 39, taxableGain) + return rowIdx + 2 + } + + private fun rsuVestScheduleRow(sheet: Sheet, rowIdx: Int, grantId: String, period: Int, vestDate: String, qty: Int): Int { + val row = sheet.createRow(rowIdx) + setText(row, 0, "Vest Schedule"); setText(row, 10, grantId) + setNum(row, 24, period.toDouble()); setText(row, 25, vestDate); setNum(row, 32, qty.toDouble()) + return rowIdx + 1 + } + + private fun writeHeader(sheet: Sheet, headers: List) { + val row = sheet.createRow(0) + headers.forEachIndexed { i, name -> setText(row, i, name) } + } + + private fun setText(row: Row, col: Int, value: String) { + row.createCell(col).setCellValue(value) + } + + private fun setNum(row: Row, col: Int, value: Double) { + row.createCell(col).setCellValue(value) + } +} diff --git a/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt index 9d594a3..d2b015e 100644 --- a/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt @@ -3,68 +3,66 @@ package cz.solutions.cockroach import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.within import org.joda.time.LocalDate -import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test -import java.io.File class ETradeBenefitHistoryParserTest { companion object { - private val ACTUAL_FILE = File("input/BenefitHistory.xlsx") + private const val FIXTURE = "/cz/solutions/cockroach/BenefitHistory.xlsx" private const val EPS = 0.001 } + private fun parseFixture(): ETradeBenefitHistoryResult = + checkNotNull(this::class.java.getResourceAsStream(FIXTURE)) { "missing test fixture $FIXTURE" } + .use { ETradeBenefitHistoryParser.parse(it) } + @Test fun parsesEsppPurchasesFromBenefitHistory() { - assumeTrue(ACTUAL_FILE.exists(), "input/BenefitHistory.xlsx not available, skipping") - - val result = ETradeBenefitHistoryParser.parse(ACTUAL_FILE) + val result = parseFixture() assertThat(result.esppRecords).hasSize(3) val purchase2025Q1 = result.esppRecords.single { it.purchaseDate == LocalDate(2025, 3, 15) } - assertThat(purchase2025Q1.quantity).isEqualTo(177.0, within(EPS)) - assertThat(purchase2025Q1.purchasePrice).isEqualTo(42.143, within(EPS)) - assertThat(purchase2025Q1.subscriptionFmv).isEqualTo(49.58, within(EPS)) - assertThat(purchase2025Q1.purchaseFmv).isEqualTo(50.92, within(EPS)) - assertThat(purchase2025Q1.symbol).isNotBlank() + assertThat(purchase2025Q1.quantity).isEqualTo(100.0, within(EPS)) + assertThat(purchase2025Q1.purchasePrice).isEqualTo(10.00, within(EPS)) + assertThat(purchase2025Q1.subscriptionFmv).isEqualTo(12.00, within(EPS)) + assertThat(purchase2025Q1.purchaseFmv).isEqualTo(15.00, within(EPS)) + assertThat(purchase2025Q1.symbol).isEqualTo("ACME") assertThat(purchase2025Q1.broker).isEqualTo("Morgan Stanley & Co.") val purchase2025Q3 = result.esppRecords.single { it.purchaseDate == LocalDate(2025, 9, 15) } - assertThat(purchase2025Q3.purchaseFmv).isEqualTo(86.76, within(EPS)) + assertThat(purchase2025Q3.purchaseFmv).isEqualTo(20.00, within(EPS)) val purchase2026Q1 = result.esppRecords.single { it.purchaseDate == LocalDate(2026, 3, 15) } - assertThat(purchase2026Q1.purchaseFmv).isEqualTo(61.50, within(EPS)) + assertThat(purchase2026Q1.purchaseFmv).isEqualTo(18.00, within(EPS)) } @Test fun parsesRsuVestsFromBenefitHistory() { - assumeTrue(ACTUAL_FILE.exists(), "input/BenefitHistory.xlsx not available, skipping") - - val result = ETradeBenefitHistoryParser.parse(ACTUAL_FILE) + val result = parseFixture() - // Future grants (e.g. 3-89431 with no vested shares) must be excluded. - assertThat(result.rsuRecords.map { it.grantId }).containsOnly("3-82769", "3-74067") + // Future grants (G-1003 with no vested shares) must be excluded. + assertThat(result.rsuRecords.map { it.grantId }).containsOnly("G-1001", "G-1002") - val grant82769 = result.rsuRecords.filter { it.grantId == "3-82769" } - assertThat(grant82769).hasSize(4) - assertThat(grant82769).allSatisfy { record -> - assertThat(record.symbol).isNotBlank() + val grant1001 = result.rsuRecords.filter { it.grantId == "G-1001" } + assertThat(grant1001).hasSize(4) + assertThat(grant1001).allSatisfy { record -> + assertThat(record.symbol).isEqualTo("ACME") assertThat(record.broker).isEqualTo("Morgan Stanley & Co.") } - assertThat(grant82769.first { it.vestDate == LocalDate(2025, 6, 20) }) - .satisfies({ assertThat(it.quantity).isEqualTo(66) }, - { assertThat(it.vestFmv).isEqualTo(52.870, within(EPS)) }) - assertThat(grant82769.first { it.vestDate == LocalDate(2025, 9, 20) }.vestFmv) - .isEqualTo(87.870, within(EPS)) + assertThat(grant1001.first { it.vestDate == LocalDate(2025, 6, 20) }) + .satisfies({ assertThat(it.quantity).isEqualTo(50) }, + { assertThat(it.vestFmv).isEqualTo(15.00, within(EPS)) }) + assertThat(grant1001.first { it.vestDate == LocalDate(2025, 9, 20) }.vestFmv) + .isEqualTo(20.00, within(EPS)) - val grant74067 = result.rsuRecords.filter { it.grantId == "3-74067" } - assertThat(grant74067).hasSize(4) - assertThat(grant74067.first { it.vestDate == LocalDate(2025, 6, 20) }) - .satisfies({ assertThat(it.quantity).isEqualTo(829) }, - { assertThat(it.vestFmv).isEqualTo(52.870, within(EPS)) }) - assertThat(grant74067.first { it.vestDate == LocalDate(2026, 3, 20) }) - .satisfies({ assertThat(it.quantity).isEqualTo(208) }, - { assertThat(it.vestFmv).isEqualTo(65.450, within(EPS)) }) + val grant1002 = result.rsuRecords.filter { it.grantId == "G-1002" } + assertThat(grant1002).hasSize(4) + assertThat(grant1002.first { it.vestDate == LocalDate(2025, 6, 20) }) + .satisfies({ assertThat(it.quantity).isEqualTo(200) }, + { assertThat(it.vestFmv).isEqualTo(15.00, within(EPS)) }) + assertThat(grant1002.first { it.vestDate == LocalDate(2026, 3, 20) }) + .satisfies({ assertThat(it.quantity).isEqualTo(200) }, + { assertThat(it.vestFmv).isEqualTo(25.00, within(EPS)) }) } } diff --git a/src/test/resources/cz/solutions/cockroach/BenefitHistory.xlsx b/src/test/resources/cz/solutions/cockroach/BenefitHistory.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..23018bcd59e50b65c591e6a5b3d0ea292b80d697 GIT binary patch literal 5275 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 PGVCi1yV-b`aA*GlEnl`! literal 0 HcmV?d00001 From e16ceeba80712d5977ff9697e19f55376dd6ee8c Mon Sep 17 00:00:00 2001 From: jimarek Date: Mon, 27 Apr 2026 20:05:49 +0200 Subject: [PATCH 31/31] remove fixture generator --- .../BenefitHistoryFixtureGenerator.kt | 132 ------------------ 1 file changed, 132 deletions(-) delete mode 100644 src/test/kotlin/cz/solutions/cockroach/BenefitHistoryFixtureGenerator.kt diff --git a/src/test/kotlin/cz/solutions/cockroach/BenefitHistoryFixtureGenerator.kt b/src/test/kotlin/cz/solutions/cockroach/BenefitHistoryFixtureGenerator.kt deleted file mode 100644 index 334f1b5..0000000 --- a/src/test/kotlin/cz/solutions/cockroach/BenefitHistoryFixtureGenerator.kt +++ /dev/null @@ -1,132 +0,0 @@ -package cz.solutions.cockroach - -import org.apache.poi.ss.usermodel.Row -import org.apache.poi.ss.usermodel.Sheet -import org.apache.poi.xssf.usermodel.XSSFWorkbook -import java.io.OutputStream - -/** - * Builds a synthetic E-Trade BenefitHistory.xlsx fixture that mirrors the column layout of a real - * export from the Morgan Stanley Stock Plan Connect portal (sheet names, header rows, the - * Grant/Vest Schedule/Tax Withholding row sequence, and the duplicated "Vested Qty." header that - * [ETradeBenefitHistoryParser] relies on). Symbols, grant ids, quantities, and amounts are all - * anonymised; only the structural layout is preserved. - */ -object BenefitHistoryFixtureGenerator { - - private val ESPP_HEADER = listOf( - "Record Type", "Symbol", "Purchase Date", "Purchase Price", "Purchased Qty.", - "Tax Collection Shares", "Net Shares", "Sellable Qty.", "Est. Market Value", - "Grant Date", "Discount Percent", "Grant Date FMV", "Purchase Date FMV", - "Qualified Plan?", "Contribution Source", "Pending Sale Qty.", "Blocked Qty.", - "Transferable Date", "First Sellable Date", "Date", "Event Type", "Qty" - ) - - private val RSU_HEADER = listOf( - "Record Type", "Symbol", "Grant Date", "Settlement Type", "Granted Qty.", - "Withheld Qty.", "Vested Qty.", "Deferred / Pending Release Qty.", "Sellable Qty.", - "Est. Market Value", "Grant Number", "Achieved Qty.", "Unvested Qty.", "Type", - "Unreleased Dividend Value", "Award Price", "Class", "Status", "Pending Sale Qty.", - "Blocked Qty.", "Cancelled Qty.", "Date", "Event Type", "Qty. or Amount", - "Vest Period", "Vest Date", "Deferred Until", "Granted Qty.", "Achieved Qty.", - "Reason for cancelled qty", "Cancelled Qty.", "Date Cancelled", "Vested Qty.", - "Released Qty", "Released Amount", "Sellable Qty.", "Blocked Qty.", - "Total Taxes Paid", "Tax Description", "Taxable Gain", "Effective Tax Rate", - "Withholding Amount" - ) - - private const val SYMBOL = "ACME" - - fun write(out: OutputStream) { - XSSFWorkbook().use { wb -> - writeEsppSheet(wb.createSheet("ESPP")) - writeRsuSheet(wb.createSheet("Restricted Stock")) - wb.write(out) - } - } - - private fun writeEsppSheet(sheet: Sheet) { - writeHeader(sheet, ESPP_HEADER) - // Three purchases: one in 2025-Q1, one in 2025-Q3, one in 2026-Q1. Each followed by an Event row. - var r = 1 - r = esppPurchase(sheet, r, date = "15-MAR-2025", purchaseFmv = 15.00) - r = esppEvent(sheet, r, date = "03/15/2025") - r = esppPurchase(sheet, r, date = "15-SEP-2025", purchaseFmv = 20.00) - r = esppEvent(sheet, r, date = "09/15/2025") - r = esppPurchase(sheet, r, date = "15-MAR-2026", purchaseFmv = 18.00) - r = esppEvent(sheet, r, date = "03/15/2026") - // Totals row (parser ignores it; included for fidelity). - sheet.createRow(r).also { setText(it, 0, "Totals"); setNum(it, 4, 300.0) } - } - - private fun esppPurchase(sheet: Sheet, rowIdx: Int, date: String, purchaseFmv: Double): Int { - val row = sheet.createRow(rowIdx) - setText(row, 0, "Purchase"); setText(row, 1, SYMBOL); setText(row, 2, date) - setNum(row, 3, 10.00); setNum(row, 4, 100.0); setNum(row, 11, 12.00); setNum(row, 12, purchaseFmv) - return rowIdx + 1 - } - - private fun esppEvent(sheet: Sheet, rowIdx: Int, date: String): Int { - val row = sheet.createRow(rowIdx) - setText(row, 0, "Event"); setText(row, 19, date); setText(row, 20, "PURCHASE"); setNum(row, 21, 100.0) - return rowIdx + 1 - } - - private fun writeRsuSheet(sheet: Sheet) { - writeHeader(sheet, RSU_HEADER) - var r = 1 - // Grant G-1001 - small grant, four vested tranches across 2025-2026. - r = rsuGrant(sheet, r, "G-1001") - r = rsuVestPair(sheet, r, "G-1001", period = 1, vestDate = "06/20/2025", qty = 50, taxableGain = 750.00) - r = rsuVestPair(sheet, r, "G-1001", period = 2, vestDate = "09/20/2025", qty = 50, taxableGain = 1000.00) - r = rsuVestPair(sheet, r, "G-1001", period = 3, vestDate = "12/20/2025", qty = 50, taxableGain = 1100.00) - r = rsuVestPair(sheet, r, "G-1001", period = 4, vestDate = "03/20/2026", qty = 50, taxableGain = 1250.00) - // Grant G-1002 - larger grant, four vested tranches. - r = rsuGrant(sheet, r, "G-1002") - r = rsuVestPair(sheet, r, "G-1002", period = 1, vestDate = "06/20/2025", qty = 200, taxableGain = 3000.00) - r = rsuVestPair(sheet, r, "G-1002", period = 2, vestDate = "09/20/2025", qty = 200, taxableGain = 4000.00) - r = rsuVestPair(sheet, r, "G-1002", period = 3, vestDate = "12/20/2025", qty = 200, taxableGain = 4400.00) - r = rsuVestPair(sheet, r, "G-1002", period = 4, vestDate = "03/20/2026", qty = 200, taxableGain = 5000.00) - // Grant G-1003 - future grant with no vested shares (parser must skip it entirely). - r = rsuGrant(sheet, r, "G-1003") - r = rsuVestScheduleRow(sheet, r, "G-1003", period = 1, vestDate = "06/20/2027", qty = 0) - r = rsuVestScheduleRow(sheet, r, "G-1003", period = 2, vestDate = "09/20/2027", qty = 0) - sheet.createRow(r).also { setText(it, 0, "Totals") } - } - - private fun rsuGrant(sheet: Sheet, rowIdx: Int, grantId: String): Int { - val row = sheet.createRow(rowIdx) - setText(row, 0, "Grant"); setText(row, 1, SYMBOL); setText(row, 10, grantId) - return rowIdx + 1 - } - - private fun rsuVestPair(sheet: Sheet, rowIdx: Int, grantId: String, period: Int, vestDate: String, qty: Int, taxableGain: Double): Int { - val schedule = sheet.createRow(rowIdx) - setText(schedule, 0, "Vest Schedule"); setText(schedule, 10, grantId) - setNum(schedule, 24, period.toDouble()); setText(schedule, 25, vestDate); setNum(schedule, 32, qty.toDouble()) - val tax = sheet.createRow(rowIdx + 1) - setText(tax, 0, "Tax Withholding"); setText(tax, 10, grantId) - setNum(tax, 24, period.toDouble()); setText(tax, 38, "Czech"); setNum(tax, 39, taxableGain) - return rowIdx + 2 - } - - private fun rsuVestScheduleRow(sheet: Sheet, rowIdx: Int, grantId: String, period: Int, vestDate: String, qty: Int): Int { - val row = sheet.createRow(rowIdx) - setText(row, 0, "Vest Schedule"); setText(row, 10, grantId) - setNum(row, 24, period.toDouble()); setText(row, 25, vestDate); setNum(row, 32, qty.toDouble()) - return rowIdx + 1 - } - - private fun writeHeader(sheet: Sheet, headers: List) { - val row = sheet.createRow(0) - headers.forEachIndexed { i, name -> setText(row, i, name) } - } - - private fun setText(row: Row, col: Int, value: String) { - row.createCell(col).setCellValue(value) - } - - private fun setNum(row: Row, col: Int, value: Double) { - row.createCell(col).setCellValue(value) - } -}