diff --git a/.gitignore b/.gitignore index e3cf182..d8e8e05 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ /.idea/ /.claude/ comparison_workdir/ +/input/ +/output/ +dependency-reduced-pom.xml \ No newline at end of file diff --git a/README.md b/README.md index e2d46dc..3aa4860 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,20 @@ # 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). - -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. +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/), +[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`), 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) +section below for the exact directory structure. # Obtaining Schwab CSV export for last year @@ -24,6 +34,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 +45,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 +56,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 +67,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,44 +81,237 @@ 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. +# 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. +- **`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. + +## 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. + +# 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: + +``` +input/ +├── config.yaml +├── schwab-export.json # Schwab JSON export +├── Accounts_Degiro.xls # Degiro account statement +├── 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) +├── 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. # 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_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 +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`, `etoro`, `vub` 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.\ ![](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 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 - -# Converting to PDF +java -jar target/cockroach-0.3-SNAPSHOT.jar input/schwab-export.json 2025 ./output input/etrade -- pandoc sales_2021.md -V geometry:landscape - \--pdf-engine=/Library/TeX/texbin/pdflatex -o sales.pdf +With YAML config (Schwab and/or E-Trade and/or Degiro and/or Revolut and/or eToro and/or VÚB): -- IDEA: Tools -\> Markdown Converter +java -jar target/cockroach-0.3-SNAPSHOT.jar input/config.yaml diff --git a/media/degiro.png b/media/degiro.png new file mode 100644 index 0000000..5d8dcd2 Binary files /dev/null and b/media/degiro.png differ diff --git a/pom.xml b/pom.xml index d398f9e..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 @@ -82,7 +76,25 @@ 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 +117,11 @@ poi-ooxml 5.4.0 + + com.charleskorn.kaml + kaml-jvm + 0.61.0 + @@ -163,6 +180,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/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/CnbYearRatesSource.kt b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt new file mode 100644 index 0000000..f634d2c --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/CnbYearRatesSource.kt @@ -0,0 +1,183 @@ +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.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. + * + * 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 = 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 { + return ClasspathCnbYearRatesSource::class.java.getResourceAsStream(name)?.use { + it.reader(StandardCharsets.UTF_8).readText() + } ?: throw IllegalStateException("classpath resource not found: $name") + } +} + +/** + * 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 + * 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 + * parsing keeps working. + */ +class HttpCnbYearRatesSource( + private val cacheDir: File, + private val baseUrl: String = DEFAULT_BASE_URL, + private val safeDaysAfterYearEnd: Int = 30, + private val today: () -> LocalDate = { LocalDate.now() } +) : CnbYearRatesSource { + + override fun loadYear(year: Int): List { + val raw = loadRaw(year) + return splitByHeader(raw) + } + + 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") + if (!cacheFile.exists()) { + downloadToFile(year, cacheFile) + } + return cacheFile.readText(StandardCharsets.UTF_8) + } + + 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 = download(year, cached = false) + + private fun downloadToFile(year: Int, target: File) { + val content = download(year, cached = true) + val tmp = File(target.parentFile, "${target.name}.tmp") + tmp.writeText(content, StandardCharsets.UTF_8) + 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 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()) { + 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) + + 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" + + 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..3aa1b6b --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachConfig.kt @@ -0,0 +1,32 @@ +package cz.solutions.cockroach + +import com.charleskorn.kaml.Yaml +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import java.io.File + +@Serializable +data class CockroachConfig( + val year: Int, + val outputDir: String, + val schwab: String? = null, + val etrade: String? = null, + val etradeBenefitHistory: String? = null, + val degiro: List = emptyList(), + val revolut: RevolutConfig = RevolutConfig(), + val etoro: List = emptyList(), + val vub: List = emptyList() +) { + companion object { + fun load(file: File): CockroachConfig { + return Yaml.default.decodeFromString(serializer(), file.readText()) + } + } +} + +@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 4b214ef..e1690a0 100644 --- a/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt +++ b/src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt @@ -2,35 +2,111 @@ 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) { - if (args.size < 3) { - System.err.println("Usage: cockroach [etrade-dir]") - 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.exit(1) + try { + runCockroach(args) + } catch (e: IllegalArgumentException) { + System.err.println("Error: ${e.message}") + 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 data class Invocation(val year: Int, val outputDir: File, val sources: List) + +private fun runCockroach(args: Array) { + 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]))) + } + return Invocation(args[1].toInt(), File(args[2]), sources) +} + +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 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") + 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 (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) - fun report(schwabExportFile: File, year: Int, outputDir: File, eTradeDir: File? = null) { - val schwabExport = parseExportFile(schwabExportFile) - val eTradeExport = eTradeDir?.let { parseETradeDir(it) } ?: ParsedExport.empty() - val parsedExport = schwabExport + eTradeExport + 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( + ClasspathOrHttpCnbYearRatesSource(HttpCnbYearRatesSource(HttpCnbYearRatesSource.defaultCacheDir())), + (year - 1)..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") @@ -45,59 +121,18 @@ 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()) 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()) - } - - - - private fun parseExportFile(schwabExportFile: File): ParsedExport { - return if (schwabExportFile.extension == "json") { - JsonExportParser().parse(load(schwabExportFile)) - } else { - throw IllegalArgumentException("only .json files are supported") - } + val transitionYear = ReportGenerator.LEGISLATIVE_TRANSITION_YEAR + File(outputDir, "${dollarConversionSchema}_rsu_$transitionYear.pdf").writeBytes(data.getRsu2024Pdf()) + File(outputDir, "${dollarConversionSchema}_espp_$transitionYear.pdf").writeBytes(data.getEspp2024Pdf()) } - 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") - val dividendXlsxResult = dividentXlsFile?.let { DividendXlsxParser.parse(it)} - val eTradeXlsFile = locateSingleFile(File(eTradeDir, "sales"), "xlsx") - val eTradeCsvFile = locateSingleFile(File(eTradeDir, "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() @@ -112,8 +147,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() @@ -124,7 +160,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)}" + @@ -133,17 +169,9 @@ object CockroachMain { } println("######################################################") - println("# Recommendation (If new legislative was used in 2024) ") + println("# Recommendation (If new legislative was used in $transitionYear) ") println("# $recomendationNewLegislativeUsed2024") 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/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..32b6412 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/DegiroAccountStatementParser.kt @@ -0,0 +1,125 @@ +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_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 + + private const val BROKER_NAME = "Degiro" + + 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, symbol = record.product, broker = BROKER_NAME, country = record.country)) + } + DESC_TAX -> { + val record = parseRecord(row) ?: continue + taxes.add(TaxRecord(record.date, record.amount, record.currency, symbol = record.product, broker = BROKER_NAME)) + } + 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, 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 { + 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 + // 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? { + 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/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/DividendRecord.kt b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt index eb9705a..a5f7244 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendRecord.kt @@ -4,5 +4,12 @@ import org.joda.time.LocalDate data class DividendRecord( val date: LocalDate, - val amount: Double + val amount: Double, + 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. + * "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/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..eaa8d7c 100644 --- a/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt +++ b/src/main/kotlin/cz/solutions/cockroach/DividendReportPdfGenerator.kt @@ -1,38 +1,99 @@ 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("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.symbol, d.broker, 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.empty(), // Cenný papír + 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.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("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.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)) + ) + 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/DividendXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/DividendXlsxParser.kt index 6a1f9d4..168288c 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)) + taxes.add(TaxRecord(date, value, Currency.USD, symbol = symbol, broker = BROKER_NAME)) } else { - dividends.add(DividendRecord(date, value)) + 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 98bb71f..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, @@ -15,62 +24,160 @@ 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) } + + // 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 taxRecords = taxRecordList - .filter { interval.contains(it.date) } - .groupBy({ it.date }) { it } - .mapValues { it.value.toMutableList() } + // 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 taxReversalRecords = taxReversalRecordList - .filter { interval.contains(it.date) } - .sortedBy { it.date } + val sections = nonCzkCurrencies.map { currency -> + buildCurrencySection( + currency, + foreignDividendsByCurrency[currency].orEmpty(), + taxesByCurrency[currency].orEmpty(), + reversalsByCurrency[currency].orEmpty(), + exchangeRateProvider + ) + } - val printableDividendList = mutableListOf() + 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 - var totalBruttoDollar = 0.0 - var totalTaxDollar = 0.0 + return DividendReport(sections, czkSection) + } + + private fun buildCurrencySection( + currency: Currency, + dividendRecords: List, + taxRecords: List, + reversalRecords: List, + exchangeRateProvider: ExchangeRateProvider + ): CurrencyDividendSection { + 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 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 - if (taxRecord != null) taxCandidates.remove(taxRecord) - - if (taxRecord != null) { - totalBruttoDollar += dividendRecord.amount - totalTaxDollar += taxRecord.amount - totalBruttoCrown += dividendRecord.amount * exchange - totalTaxCrown += taxRecord.amount * exchange - - printableDividendList.add( - PrintableDividend( - 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) - ) + 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( + key.symbol, + key.broker, + DATE_FORMATTER.print(key.date), + FormatingHelper.formatDouble(divAmount), + FormatingHelper.formatExchangeRate(exchange), + FormatingHelper.formatDouble(taxAmount), + FormatingHelper.formatDouble(exchange * divAmount), + FormatingHelper.formatDouble(exchange * taxAmount) ) - } + ) } - 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 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 ((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( + key.symbol, + key.broker, + DATE_FORMATTER.print(key.date), + FormatingHelper.formatDouble(divAmount), + FormatingHelper.formatDouble(taxAmount) + ) + ) + } + + val totalTaxReversalCrown = reversalRecords.sumOf { it.amount } + return CzkDividendSection(printable, totalBruttoCrown, totalTaxCrown, totalTaxReversalCrown) + } + + 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 broker/symbol/date match the dividend." } } \ No newline at end of file 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..154774c --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParser.kt @@ -0,0 +1,174 @@ +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 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" + 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_GRANT = "Grant" + 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, + symbol = str(row, cols, COL_SYMBOL)!!, + broker = BROKER_NAME + ) + } + 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 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 + 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, + symbol = symbolByGrant[p.grantId]!!, + broker = BROKER_NAME + ) + 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/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt b/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt new file mode 100644 index 0000000..4b9fec1 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/ETradeBrokerSource.kt @@ -0,0 +1,69 @@ +package cz.solutions.cockroach + +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" + + // 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" + } + } + + override fun parse(): ParsedExport { + val benefitHistory = benefitHistoryFile?.let { ETradeBenefitHistoryParser.parse(it) } + val rsuRecords = benefitHistory?.rsuRecords + ?: directory?.let { RsuPdfParser.parseDirectory(File(it, "rsu"), brokerName) } + ?: emptyList() + val esppRecords = benefitHistory?.esppRecords + ?: 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) } + 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/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..d6121b9 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppPdfParser.kt @@ -4,28 +4,35 @@ import java.io.File object EsppPdfParser { + // 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") 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 +40,9 @@ object EsppPdfParser { purchasePrice = purchasePricePerShare, subscriptionFmv = grantDateMarketValue, purchaseFmv = purchaseValuePerShare, - purchaseDate = purchaseDate + purchaseDate = purchaseDate, + symbol = symbol, + broker = brokerName ) } diff --git a/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt b/src/main/kotlin/cz/solutions/cockroach/EsppRecord.kt index ff1698c..3d2890c 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 ccac49e..d8bcd37 100644 --- a/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/EsppReportPreparation.kt @@ -38,10 +38,12 @@ 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( + 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/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/EtoroXlsxParser.kt b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt new file mode 100644 index 0000000..e138634 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/EtoroXlsxParser.kt @@ -0,0 +1,145 @@ +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]+)"""") + + // 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) + 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 + } + // 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 — 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, symbol = instrument, broker = BROKER_NAME)) + } + } + 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/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/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/InterestRecord.kt b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt new file mode 100644 index 0000000..c13c910 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/InterestRecord.kt @@ -0,0 +1,15 @@ +package cz.solutions.cockroach + +import org.joda.time.LocalDate + +data class InterestRecord( + val date: LocalDate, + val amount: Double, + 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, + /** 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 new file mode 100644 index 0000000..2749ec3 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReport.kt @@ -0,0 +1,63 @@ +package cz.solutions.cockroach + +data class PrintableInterest( + val product: String, + val broker: String, + val date: String, + val brutto: String, + val tax: String, + val exchange: String, + val bruttoCrown: String, + val taxCrown: String, +) + +data class PrintableCzkInterest( + val product: String, + val broker: String, + val date: String, + val brutto: String, + val tax: String, +) + +data class CurrencyInterestSection( + val country: String, + val currency: Currency, + val printableInterestList: List, + val totalBrutto: Double, + val totalTax: Double, + val totalBruttoCrown: Double, + val totalTaxCrown: Double, +) + +data class CzkInterestSection( + val country: String, + val printableInterestList: List, + 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 czkSections: List, + val countryTotals: List, +) { + val totalNonCzkBruttoCrown: Double get() = sections.sumOf { it.totalBruttoCrown } + 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 new file mode 100644 index 0000000..05e155f --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReportPdfGenerator.kt @@ -0,0 +1,94 @@ +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)) + } + for (section in report.czkSections) { + pdfs.add(generateCzkSectionPdf(section)) + } + + 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 country = section.country + val columns = listOf( + PdfColumn("Produkt", 2f), 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.printableInterestList.map { i -> + listOf(i.product, i.broker, i.date, i.brutto, i.tax, i.exchange, i.bruttoCrown, i.taxCrown) + } + val fmt = FormatingHelper::formatDouble + val summaryRow = listOf( + SummaryCell.empty(), // Produkt + 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.totalTaxCrown)), + ) + return PdfReportGenerator.generate(PdfReportDefinition( + 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("Sražená daň (Kč)", 1.1f) + ) + 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.totalTaxCrown)), + ) + val title = if (country == "CZ") "Úroky ze zdrojů v ČR – rozpis" else "Úroky (§8) – rozpis – $country / CZK" + return PdfReportGenerator.generate(PdfReportDefinition( + title = title, + subtitles = listOf("Země zdroje: $country", "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..5b15fd2 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/InterestReportPreparation.kt @@ -0,0 +1,118 @@ +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) } + .map { withResolvedCountry(it) } + val grouped = interestsInInterval.groupBy { it.country to it.currency } + + 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 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) + } + + /** 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) + } + + 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 + ): CurrencyInterestSection { + 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( + interestRecord.product, + 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(taxCrown), + ) + ) + } + + return CurrencyInterestSection(country, currency, printable, totalBrutto, totalTax, totalBruttoCrown, totalTaxCrown) + } + + 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.tax), + ) + ) + } + return CzkInterestSection(country, printable, totalBruttoCrown, totalTaxCrown) + } +} diff --git a/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt b/src/main/kotlin/cz/solutions/cockroach/JsonExportParser.kt index 440436a..b8109ad 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 @@ -15,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 { @@ -33,17 +36,19 @@ class JsonExportParser { return ParsedExport( - export.transactions.filterIsInstance(Transaction.RsuDepositTransaction::class.java).map { + export.transactions.filterIsInstance().map { check(it.transactionDetails.size==1) RsuRecord( it.date, 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(Transaction.EsppDepositTransaction::class.java).map { + export.transactions.filterIsInstance().map { check(it.transactionDetails.size==1) EsppRecord( it.date, @@ -51,29 +56,41 @@ 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(Transaction.DividendTransaction::class.java).map { + export.transactions.filterIsInstance().map { DividendRecord( it.date, - it.amount + it.amount, + Currency.USD, + symbol = it.symbol, + broker = BROKER, + country = "US" ) }, - export.transactions.filterIsInstance(Transaction.TaxWithholdingTransaction::class.java).map { + export.transactions.filterIsInstance().map { TaxRecord( it.date, - it.amount + it.amount, + Currency.USD, + symbol = it.symbol, + broker = BROKER, ) }, - export.transactions.filterIsInstance(Transaction.TaxReversalTransaction::class.java).map { + export.transactions.filterIsInstance().map { TaxReversalRecord( it.date, - it.amount + it.amount, + Currency.USD, + symbol = it.symbol, + broker = BROKER, ) }, - export.transactions.filterIsInstance(Transaction.SaleTransaction::class.java).flatMap { + export.transactions.filterIsInstance().flatMap { it.transactionDetails.map {transactionDetail-> SaleRecord( it.date, @@ -83,12 +100,14 @@ class JsonExportParser { transactionDetail.details.purchasePrice(), transactionDetail.details.purchaseFmv(), transactionDetail.details.purchaseDate(), - transactionDetail.details.grantId() + transactionDetail.details.grantId(), + symbol = it.symbol, + broker = BROKER ) } }, - export.transactions.filterIsInstance(Transaction.JournalTransaction::class.java).map { + export.transactions.filterIsInstance().map { JournalRecord( it.date, it.amount?:0.0, @@ -142,7 +161,8 @@ sealed class Transaction { @Contextual override val date: LocalDate, @Contextual - val amount: Double + val amount: Double, + val symbol: String, ) : Transaction() @Serializable @@ -151,6 +171,7 @@ sealed class Transaction { override val date: LocalDate, @Contextual val amount: Double, + val symbol: String, val transactionDetails: List ) : Transaction() @@ -161,6 +182,7 @@ sealed class Transaction { override val date: LocalDate, val quantity: Int, + val symbol: String, val transactionDetails: List ) : Transaction() @@ -171,6 +193,7 @@ sealed class Transaction { override val date: LocalDate, val quantity: Int, + val symbol: String, val transactionDetails: List ) : Transaction() @@ -189,6 +212,8 @@ sealed class Transaction { @Contextual val amount: Double, + + val symbol: String, ) : Transaction() @Serializable @@ -198,6 +223,8 @@ sealed class Transaction { @Contextual val amount: Double, + + val symbol: String, ) : Transaction() @Serializable 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/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 new file mode 100644 index 0000000..75c9821 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableCzkDividend.kt @@ -0,0 +1,9 @@ +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 8bd8e12..fbd73fc 100644 --- a/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt +++ b/src/main/kotlin/cz/solutions/cockroach/PrintableDividend.kt @@ -1,10 +1,12 @@ package cz.solutions.cockroach data class PrintableDividend( + val symbol: String, + val broker: String, 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/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 88e2728..1fe5d81 100644 --- a/src/main/kotlin/cz/solutions/cockroach/Report.kt +++ b/src/main/kotlin/cz/solutions/cockroach/Report.kt @@ -14,19 +14,22 @@ 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") 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) + fun getInterestPdf(): ByteArray = InterestReportPdfGenerator.generate(interestReport) + 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) @@ -49,11 +52,18 @@ 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 interestVars = mapOf( + "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 + 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..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) @@ -19,6 +26,11 @@ object ReportGenerator { interval, exchangeRateProvider ), + interestReport = InterestReportPreparation.generateInterestReport( + parsedExport.interestRecords, + interval, + exchangeRateProvider + ), esppReport = EsppReportPreparation.generateEsppReport( parsedExport.esppRecords, parsedExport.saleRecords, @@ -33,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} ), @@ -41,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} ), 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/RevolutParser.kt b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt new file mode 100644 index 0000000..d6c534b --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/RevolutParser.kt @@ -0,0 +1,218 @@ +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 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") + + // 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") + + // 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) } + } + + 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 + val ticker = record.get("Ticker").trim() + // 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, symbol = ticker, broker = BROKER_NAME)) + } + } + 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()){ + 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, tax = 0.0, country = SAVINGS_COUNTRY)) + } + } + description.startsWith("Service Fee Charged") -> { + feeTotal += value + feeCount++ + feeCurrency = currency + } + 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." + } + 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.") + } + } + } + } + } + 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/kotlin/cz/solutions/cockroach/RsuPdfParser.kt b/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt index 149ede1..a191c0b 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuPdfParser.kt @@ -4,34 +4,43 @@ import java.io.File object RsuPdfParser { + // 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") 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 = brokerName ) } } diff --git a/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt b/src/main/kotlin/cz/solutions/cockroach/RsuRecord.kt index 3cd6617..816ae30 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 bb467e2..677e442 100644 --- a/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt +++ b/src/main/kotlin/cz/solutions/cockroach/RsuReportPreparation.kt @@ -38,12 +38,14 @@ 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 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..6605aae 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 e73a902..6533140 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 @@ -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/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/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..b4922d0 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TaxRecord.kt @@ -4,5 +4,12 @@ import org.joda.time.LocalDate data class TaxRecord( val date: LocalDate, - val amount: Double + 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 d36a738..c66e7e7 100644 --- a/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt +++ b/src/main/kotlin/cz/solutions/cockroach/TaxReversalRecord.kt @@ -4,5 +4,11 @@ import org.joda.time.LocalDate data class TaxReversalRecord( val date: LocalDate, - val amount: Double + 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/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 + ) + } +} 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..a03be83 --- /dev/null +++ b/src/main/kotlin/cz/solutions/cockroach/VubInterestPdfParser.kt @@ -0,0 +1,132 @@ +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 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+$""") + 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 -> + 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 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, 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 { + 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, tax = 0.0, country = COUNTRY)) + } + 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/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/main/resources/cz/solutions/cockroach/guide.html.hbs b/src/main/resources/cz/solutions/cockroach/guide.html.hbs index c95c4d0..640636f 100644 --- a/src/main/resources/cz/solutions/cockroach/guide.html.hbs +++ b/src/main/resources/cz/solutions/cockroach/guide.html.hbs @@ -243,7 +243,31 @@ 414 Daň ze samotného základu daně podle § 16a zákona...0 +

+ +

Interest

+

Ú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}}

+ + + + + + + + + +
ŘádekPopisVyplní v celých Kč
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/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") + } +} 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..7d3ade9 --- /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, symbol = "APPLE INC", broker = "Degiro", country = "US") + ) + assertThat(result.taxRecords).containsExactly( + TaxRecord(LocalDate(2024, 3, 15), -1.85, Currency.USD, symbol = "APPLE INC", broker = "Degiro") + ) + } + + @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, symbol = "APPLE INC", broker = "Degiro", country = "US") + ) + } + + @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, symbol = "ASML HOLDING", broker = "Degiro", country = "NL"), + DividendRecord(LocalDate(2024, 4, 2), 1234.56, Currency.CZK, symbol = "CEZ AS", broker = "Degiro", country = "CZ") + ) + } + + @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/DividendXlsxParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/DividendXlsxParserTest.kt index e5bd6f2..24f5d61 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) + 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, symbol = "CISCO SYS INC", broker = "Morgan Stanley & Co.") ) } @@ -50,10 +50,10 @@ 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, 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, 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 df2a3ce..1f3d0fe 100644 --- a/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt +++ b/src/test/kotlin/cz/solutions/cockroach/DividentReportPreparationTest.kt @@ -1,72 +1,140 @@ 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 class DividentReportPreparationTest { - private val fixedRate = ExchangeRateProvider { 25.0 } + private val fixedRate = ExchangeRateProvider { _, _ -> 25.0 } 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( dividends, taxes, emptyList(), year2025, fixedRate ) - // Both dividends should be matched - assertThat(report.printableDividendList).hasSize(2) + val usd = report.sections.single { it.currency == Currency.USD } - // The total tax should include BOTH tax records, not -8.86 twice - assertThat(report.totalTaxDollar).isCloseTo(-436.90, Offset.offset(0.01)) + // One row per (broker, symbol, date) bucket. + assertThat(usd.printableDividendList).hasSize(2) - // Each tax should be used exactly once - assertThat(report.totalTaxCrown).isCloseTo(-436.90 * 25.0, Offset.offset(0.1)) + // 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)) + 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)) - val taxes = listOf(TaxRecord(LocalDate(2025, 1, 22), -150.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( 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 + 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, Currency.USD, symbol = "ACME", broker = "Schwab", country = "US") + ) + + 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( - 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), - 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( 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)) } } 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..d2b015e --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/ETradeBenefitHistoryParserTest.kt @@ -0,0 +1,68 @@ +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 + +class ETradeBenefitHistoryParserTest { + + companion object { + 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() { + val result = parseFixture() + + assertThat(result.esppRecords).hasSize(3) + + val purchase2025Q1 = result.esppRecords.single { it.purchaseDate == LocalDate(2025, 3, 15) } + 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(20.00, within(EPS)) + + val purchase2026Q1 = result.esppRecords.single { it.purchaseDate == LocalDate(2026, 3, 15) } + assertThat(purchase2026Q1.purchaseFmv).isEqualTo(18.00, within(EPS)) + } + + @Test + fun parsesRsuVestsFromBenefitHistory() { + val result = parseFixture() + + // Future grants (G-1003 with no vested shares) must be excluded. + assertThat(result.rsuRecords.map { it.grantId }).containsOnly("G-1001", "G-1002") + + 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(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 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/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/ETradeGainLossParserTest.kt index 29ca12a..40ccd83 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." ) ) @@ -62,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)), + 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/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..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( @@ -42,7 +44,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 = brokerName ) ) } @@ -68,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( @@ -77,7 +81,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 = brokerName ) ) } @@ -86,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( @@ -95,7 +101,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 = brokerName ) ) } 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..bfd9c8e --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/EtoroXlsxParserTest.kt @@ -0,0 +1,168 @@ +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 +import java.util.zip.ZipEntry +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") + 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", 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, symbol = "AAPL", broker = "eToro"), + TaxRecord(LocalDate(2025, 6, 15), -0.50, Currency.USD, symbol = "VOD.L", broker = "eToro"), + ) + } + + @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", country = "US") + ) + assertThat(result.taxRecords).containsExactly( + TaxRecord(LocalDate(2025, 1, 2), -0.15, Currency.USD, symbol = "AAPL", broker = "eToro") + ) + } + + @Test + fun headerMismatchFailsFast(@TempDir tempDir: File) { + val file = File(tempDir, "etoro.xlsx") + writeEtoroLikeXlsx(file, rows = emptyList(), headers = listOf( + "Wrong", "Instrument Name", "Net Dividend Received", + "x", "x", "x", "x", "x", "Withholding Tax Amount", + )) + + val ex = assertThrows(IllegalArgumentException::class.java) { + EtoroXlsxParser.parse(file) + } + assertThat(ex.message).contains("unexpected header in column A") + } + + // --- synthetic XLSX helpers -------------------------------------------------- + + private data class EtoroRow(val date: String, val instrument: String, val net: Double, val wht: Double) + + 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("""""") + pool.keys.forEach { append("").append(escapeXml(it)).append("") } + append("") + } + + val sheetXml = buildString { + append("""""") + append("""""") + // 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, h -> + append("""${pool[h]}""") + } + append("") + rows.forEachIndexed { idx, row -> + val rNum = idx + 2 + append("""""") + append("""${pool[row.date]}""") + append("""${pool[row.instrument]}""") + append("""${row.net}""") + append("""${row.wht}""") + append("") + } + append("") + } + + ZipOutputStream(file.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("xl/sharedStrings.xml")) + 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() + } + } + + 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 + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} 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..3a118df --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/InterestReportPreparationTest.kt @@ -0,0 +1,123 @@ +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, 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( + records, DateInterval.year(2025), fixedRate + ) + + assertThat(report.sections).hasSize(2) + 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 + 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, country = "CZ"), + interestRecord(LocalDate(2025, 7, 7), 100.0, Currency.USD, country = "IE"), + ) + + 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!!.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) + } +} diff --git a/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/JsonExportParserTest.kt index 61274f0..917ca31 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,24 +36,36 @@ 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, + Currency.USD, + symbol = "CSCO", + broker = "Charles Schwab & Co.", + country = "US" + ) ), listOf( TaxRecord( LocalDate(2023,10,25), - -12.66 + -12.66, + Currency.USD, + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ), listOf( TaxReversalRecord( LocalDate(2023,2,7), - 1.88 + 1.88, + Currency.USD, + symbol = "CSCO", + broker = "Charles Schwab & Co." ) ), listOf( @@ -63,7 +77,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 +89,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/RevolutParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt new file mode 100644 index 0000000..b5de444 --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/RevolutParserTest.kt @@ -0,0 +1,139 @@ +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 +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 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 = """ + 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 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. + 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)) + } + +} diff --git a/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt b/src/test/kotlin/cz/solutions/cockroach/RsuPdfParserTest.kt index 405261f..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( @@ -36,7 +38,9 @@ class RsuPdfParserTest { quantity = 53, vestFmv = 79.51, vestDate = LocalDate(2025, 12, 10), - grantId = "1623675" + grantId = "1623675", + symbol = "CSCO", + broker = brokerName ) ) } @@ -58,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( @@ -66,7 +70,9 @@ class RsuPdfParserTest { quantity = 18, vestFmv = 67.34, vestDate = LocalDate(2025, 9, 10), - grantId = "1679633" + grantId = "1679633", + symbol = "CSCO", + broker = brokerName ) ) } @@ -75,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( @@ -83,7 +89,9 @@ class RsuPdfParserTest { quantity = 53, vestFmv = 79.51, vestDate = LocalDate(2025, 12, 10), - grantId = "1623675" + grantId = "1623675", + symbol = "CSCO", + broker = brokerName ) ) } @@ -97,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 -> @@ -107,7 +115,9 @@ class RsuPdfParserTest { quantity = 53, vestFmv = 79.51, vestDate = LocalDate(2025, 12, 10), - grantId = "1623675" + grantId = "1623675", + symbol = "CSCO", + broker = brokerName ) ) } @@ -120,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) } diff --git a/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt b/src/test/kotlin/cz/solutions/cockroach/SalesReportPreparationTest.kt index f08f3a2..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, @@ -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, @@ -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, @@ -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 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..8aa1061 --- /dev/null +++ b/src/test/kotlin/cz/solutions/cockroach/TestRecords.kt @@ -0,0 +1,133 @@ +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", + country: String = "US", +): DividendRecord = DividendRecord( + date = date, + amount = amount, + currency = currency, + symbol = symbol, + broker = broker, + country = country, +) + +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, +) + +fun taxRecord( + date: LocalDate, + amount: Double, + currency: Currency = Currency.USD, + 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, + symbol: String = "TEST", + broker: String = "TestBroker", +): TaxReversalRecord = TaxReversalRecord( + date = date, + amount = amount, + currency = currency, + symbol = symbol, + broker = broker, +) + +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, +) 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 + } + } +} 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 0000000..23018bc Binary files /dev/null and b/src/test/resources/cz/solutions/cockroach/BenefitHistory.xlsx differ