Author SHA1 Message Date
natercio 3dfc7bd6a7 better domain isolation
Generate check / check-changes (pull_request) Successful in 26s
Generate check / verify-generate (pull_request) Skipped
Quality / check-changes (pull_request) Successful in 2s
Quality / static-checks (pull_request) Failing after 16s
Quality / run-tests (pull_request) Failing after 56s
2026-10-05 14:56:27 +01:00
natercio ebb471524e addressed consistency 2026-10-05 12:19:21 +01:00
natercio 3b971cda61 print as html 2026-10-05 10:33:02 +01:00
natercio bb00676452 writers in writing package 2026-09-28 21:50:26 +01:00
natercio 4604175e8d better lifecycle management 2026-09-28 21:46:54 +01:00
natercio d32f2b3a66 initial webapp 2026-09-28 21:46:54 +01:00
20 changed files with 770 additions and 433 deletions

No files matched your search

+39 -8
View File
@@ -20,16 +20,10 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
## Usage ## Usage
```bash ```bash
cat statement.csv | any2anexoj-cli --platform=trading212 cat statement.csv | any2anexoj-cli --platform=tranding212
``` ```
`trading212` is currently the only supported platform. The output format is selected with `-f`/`--format`: Pass `--format html` to emit a complete, standalone Bootstrap-styled HTML report (a `<!DOCTYPE html>` document with the Anexo J table embedded and Bootstrap loaded via CDN); the default `--format table` prints the pretty terminal table and `--format csv` writes the Anexo J CSV schema.
- `table` (default): pretty terminal table
- `csv`: the Anexo J CSV schema
- `html`: a complete, standalone Bootstrap-styled HTML report — a `<!DOCTYPE html>` document with the Anexo J table embedded and the official Bootstrap 5.3.3 stylesheet inlined via `go:embed`, so the report renders fully offline
The `table` and `html` formats are translated according to `--language`/`-l` (`pt` by default, `en` available). Other flags: `--selectors` filters which entries are processed (`code`, `assetCountry`), `--open-figi-api-key` raises OpenFIGI rate limits for faster report generation, and `--debug` enables debug logging.
### Incremental Reports ### Incremental Reports
@@ -61,3 +55,40 @@ State is only persisted once the input reaches EOF, so a crash or `Ctrl-C` befor
All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision. All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision.
There are no explicit rules or details about how to round Euro values in Anexo J. There are no explicit rules or details about how to round Euro values in Anexo J.
This application rounds according to `Portaria n.º 1180/2001, art. 2.º, alínea c) e d)` (Ministerial Order / Government Order) examples, which imply we should round to the 2nd decimal place by rounding up (ceiling) or down (floor) depending on whether the third decimal place is ≥ 5 or < 5, respectively. This application rounds according to `Portaria n.º 1180/2001, art. 2.º, alínea c) e d)` (Ministerial Order / Government Order) examples, which imply we should round to the 2nd decimal place by rounding up (ceiling) or down (floor) depending on whether the third decimal place is ≥ 5 or < 5, respectively.
## Web app
A minimal single-user web frontend is bundled in the `cmd/any2anexoj-web` binary. It reuses the existing `internal/trading212` parser, the `internal.BuildReport` pipeline, and the `internal/prettyprinter` formatter, so the rendered table is byte-for-byte identical to the CLI output.
### Run
```bash
go run ./cmd/any2anexoj-web
```
The server listens on `:8080` by default. Override the port with the `PORT` environment variable:
```bash
PORT=9000 go run ./cmd/any2anexoj-web
```
Then open <http://localhost:8080> in a browser, pick a Trading212 CSV file, and click **Upload**. The rendered Anexo J table appears below the form along with the row count.
### API
The web app exposes two JSON endpoints. Auth is intentionally stubbed (a no-op middleware), and state is kept in process memory only — single user, no persistence.
| Method | Path | Description |
|--------|----------------|---------------------------------------------------------------------------------------------------|
| POST | `/api/upload` | `multipart/form-data` with a `csv` file field. Returns `{"table": "<rendered>", "rows": <int>}`. |
| GET | `/api/table` | Returns the most recently rendered table (`{"table": "", "rows": 0}` if none uploaded yet). |
Example:
```bash
curl -F csv=@trading212-small.csv http://localhost:8080/api/upload
curl http://localhost:8080/api/table
```
> [!NOTE]
> The web app is single-user and in-memory. Uploads overwrite the previously stored table, and restarting the server clears all state. Auth is a stub — do not expose this service to the public internet without adding real authentication and per-user state first.
+1 -1
View File
@@ -28,7 +28,7 @@ var (
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code") lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages") debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
format = pflag.StringP("format", "f", "table", "Output format: table, csv or html") format = pflag.StringP("format", "f", "table", "Output format: table, csv or html")
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better api rate limits)") ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better rate api rate limits)")
selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry") selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry")
stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing") stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing")
) )
+202
View File
@@ -0,0 +1,202 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/pflag"
)
// resetFlags puts every pflag-backed variable back to its default so each
// test that calls runWithIO sees a clean slate regardless of test ordering
// or arguments the previous test set via pflag.Set.
func resetFlags(t *testing.T) {
t.Helper()
if err := pflag.Set("platform", "trading212"); err != nil {
t.Fatalf("reset platform flag: %v", err)
}
if err := pflag.Set("language", "en"); err != nil {
t.Fatalf("reset language flag: %v", err)
}
if err := pflag.Set("debug", "false"); err != nil {
t.Fatalf("reset debug flag: %v", err)
}
if err := pflag.Set("format", "table"); err != nil {
t.Fatalf("reset format flag: %v", err)
}
if err := pflag.Set("open-figi-api-key", ""); err != nil {
t.Fatalf("reset open-figi-api-key flag: %v", err)
}
if err := pflag.Set("selectors", ""); err != nil {
t.Fatalf("reset selectors flag: %v", err)
}
if err := pflag.Set("state-file", ""); err != nil {
t.Fatalf("reset state-file flag: %v", err)
}
}
// trading212SampleCSV is a minimal Trading212 export with a header row and
// one market buy + one matching market sell so BuildReport reaches EOF and
// exercises the store.Save path. The line format mirrors the fixtures in
// internal/trading212/record_test.go (20 columns).
const trading212SampleCSV = `Action,Time,ISIN,Ticker,Name,Notes,Quantity,Price,Price currency,Exchange rate,Result,Result currency,Charges,Charges currency,Stamp duty,Stamp duty currency,Conversion fee,Conversion fee currency,French transaction tax,French transaction tax currency
Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,
Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"
`
// runWithStdin runs runWithIO against the supplied stdin payload.
func runWithStdin(t *testing.T, stdin string, stdout *bytes.Buffer) error {
t.Helper()
if err := runWithIO(t.Context(), strings.NewReader(stdin), stdout); err != nil {
return err
}
return nil
}
// TestRunWithIO_StateFileCreated verifies that running the CLI with
// --state-file produces a state file on disk after a successful EOF.
func TestRunWithIO_StateFileCreated(t *testing.T) {
resetFlags(t)
t.Cleanup(func() { resetFlags(t) })
dir := t.TempDir()
statePath := filepath.Join(dir, "state.json")
if err := pflag.Set("state-file", statePath); err != nil {
t.Fatalf("set state-file flag: %v", err)
}
if err := pflag.Set("format", "csv"); err != nil {
t.Fatalf("set format flag: %v", err)
}
var stdout bytes.Buffer
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
}
info, err := os.Stat(statePath)
if err != nil {
t.Fatalf("expected state file at %s but stat returned error: %v", statePath, err)
}
if info.Size() == 0 {
t.Fatalf("state file at %s is empty", statePath)
}
// State file must look like JSON with the expected version field.
body, err := os.ReadFile(statePath)
if err != nil {
t.Fatalf("read state file: %v", err)
}
if !bytes.Contains(body, []byte(`"version"`)) {
t.Errorf("state file missing version field, got: %s", body)
}
if !bytes.Contains(body, []byte(`"trading212"`)) {
t.Errorf("state file missing trading212 platform, got: %s", body)
}
}
// TestRunWithIO_NoStateFileByDefault verifies that omitting --state-file
// behaves exactly like the pre-persistence CLI: nothing is written to disk
// and the report still renders.
func TestRunWithIO_NoStateFileByDefault(t *testing.T) {
resetFlags(t)
t.Cleanup(func() { resetFlags(t) })
// Use a temp working directory so any accidental file write would
// show up clearly via t.TempDir's cleanup listing.
dir := t.TempDir()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := pflag.Set("format", "csv"); err != nil {
t.Fatalf("set format flag: %v", err)
}
var stdout bytes.Buffer
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read tmp dir: %v", err)
}
for _, e := range entries {
t.Errorf("unexpected file written without --state-file: %s", e.Name())
}
if stdout.Len() == 0 {
t.Errorf("expected non-empty csv output on stdout")
}
}
// TestRunWithIO_HTMLFormat verifies that --format html produces a complete
// HTML document on stdout starting with the DOCTYPE declaration.
func TestRunWithIO_HTMLFormat(t *testing.T) {
resetFlags(t)
t.Cleanup(func() { resetFlags(t) })
if err := pflag.Set("format", "html"); err != nil {
t.Fatalf("set format flag: %v", err)
}
var stdout bytes.Buffer
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
}
got := stdout.String()
if !strings.HasPrefix(got, "<!DOCTYPE html>") {
t.Errorf("expected HTML output to start with <!DOCTYPE html>; got prefix: %q",
got[:min(len(got), 64)])
}
if !strings.Contains(got, "<h1>Tax report</h1>") {
t.Errorf("expected output to contain the translated report title")
}
if !strings.Contains(got, "table-responsive") {
t.Errorf("expected output to contain Bootstrap table-responsive wrapper")
}
if !strings.Contains(got, "table-striped") {
t.Errorf("expected output to contain Bootstrap table-striped class")
}
if !strings.Contains(got, "</html>") {
t.Errorf("expected output to end with </html>")
}
}
// TestRunWithIO_UnsupportedPlatformForPersistence verifies that using
// --state-file with an unknown platform surfaces a clear error rather than
// silently falling back to EphemeralStore.
func TestRunWithIO_UnsupportedPlatformForPersistence(t *testing.T) {
resetFlags(t)
t.Cleanup(func() { resetFlags(t) })
if err := pflag.Set("state-file", filepath.Join(t.TempDir(), "state.json")); err != nil {
t.Fatalf("set state-file flag: %v", err)
}
// Currently only trading212 is wired through buildStore, but the
// reader switch also only supports trading212, so the reader error
// fires first. Either error is acceptable; we just need a clear
// failure message.
if err := pflag.Set("platform", "unknown-broker"); err != nil {
t.Fatalf("set platform flag: %v", err)
}
var stdout bytes.Buffer
err := runWithStdin(t, trading212SampleCSV, &stdout)
if err == nil {
t.Fatalf("expected an error for unsupported platform")
}
if !strings.Contains(err.Error(), "platform") {
t.Errorf("expected error to mention platform, got: %v", err)
}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
"embed"
"errors"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/nmoniz/any2anexoj/internal/web"
"golang.org/x/sync/errgroup"
)
//go:embed all:static
var staticFS embed.FS
func main() {
if err := run(); err != nil {
slog.Error("any2anexoj-web: fatal error", slog.Any("err", err))
os.Exit(1)
}
slog.Info("shutdown gracefully")
}
// run wires up the server, installs signal-driven cancellation, and blocks on
// the HTTP server until it stops. It is split out from main so tests can drive
// the same code paths if needed.
func run() error {
addr := os.Getenv("PORT")
if addr == "" {
addr = ":8080"
}
// A bare "8080" value is friendlier than the net.Listen-style ":8080".
if addr[0] != ':' {
addr = ":" + addr
}
// Sub-mount the embedded FS so the static subtree is rooted at "" inside
// the web package (matches /static/...).
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
return err
}
state := web.NewState()
srv, err := web.NewServer(state, staticSub)
if err != nil {
return err
}
httpServer := &http.Server{
Addr: addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt, syscall.SIGTERM)
defer cancel()
eg, ctx := errgroup.WithContext(ctx)
eg.Go(func() error {
slog.Info("listening for http", slog.Any("port", addr))
err := httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
})
eg.Go(func() error {
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return err
}
return nil
})
return eg.Wait()
}
+108
View File
@@ -0,0 +1,108 @@
(function () {
"use strict";
var UPLOAD_URL = "/api/upload";
function $(id) {
return document.getElementById(id);
}
function setStatus(message) {
var status = $("status");
status.textContent = message || "";
}
function setError(message) {
var error = $("error");
error.textContent = message || "";
}
function clearResult() {
var result = $("result");
var rowsInfo = $("rows-info");
var tableOutput = $("table-output");
rowsInfo.textContent = "";
tableOutput.textContent = "";
result.hidden = true;
}
function showResult(rows, table) {
var result = $("result");
var rowsInfo = $("rows-info");
var tableOutput = $("table-output");
rowsInfo.textContent = "Rows: " + rows;
tableOutput.textContent = table || "";
result.hidden = false;
}
function uploadFile(file) {
var formData = new FormData();
formData.append("csv", file);
return fetch(UPLOAD_URL, {
method: "POST",
body: formData
}).then(function (response) {
return response.json().then(function (data) {
return { ok: response.ok, status: response.status, data: data };
});
});
}
function handleSubmit(event) {
event.preventDefault();
setError("");
clearResult();
var fileInput = $("csv");
if (!fileInput.files || fileInput.files.length === 0) {
setError("Please choose a CSV file before uploading.");
return;
}
var file = fileInput.files[0];
var button = $("upload-button");
button.disabled = true;
setStatus("Uploading " + file.name + "...");
uploadFile(file).then(function (result) {
button.disabled = false;
if (!result.ok) {
setStatus("");
var serverMsg = result.data && result.data.error ? result.data.error : "";
var msg = "Upload failed (HTTP " + result.status + ").";
if (serverMsg) {
msg += " " + serverMsg;
}
setError(msg);
return;
}
if (result.data && typeof result.data.error === "string" && result.data.error.length > 0) {
setStatus("");
setError(result.data.error);
return;
}
var table = result.data && typeof result.data.table === "string" ? result.data.table : "";
var rows = result.data && typeof result.data.rows === "number" ? result.data.rows : 0;
setStatus("Upload complete.");
showResult(rows, table);
}).catch(function (err) {
button.disabled = false;
setStatus("");
var msg = err && err.message ? err.message : String(err);
setError("Network error while uploading: " + msg);
});
}
document.addEventListener("DOMContentLoaded", function () {
var form = $("upload-form");
if (form) {
form.addEventListener("submit", handleSubmit);
}
});
})();
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>any2anexoj web</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<h1>any2anexoj web</h1>
<p class="lede">Upload a Trading212-style CSV and view the rendered tax report.</p>
<form id="upload-form" class="upload-form" novalidate>
<label class="file-label" for="csv">Choose CSV file</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv">
<button type="submit" id="upload-button">Upload</button>
</form>
<div id="status" class="status" role="status" aria-live="polite"></div>
<div id="error" class="error" role="alert" aria-live="assertive"></div>
<section id="result" class="result" hidden>
<h2 id="result-heading">Result</h2>
<p id="rows-info" class="rows-info"></p>
<pre id="table-output" class="table-output"></pre>
</section>
</main>
<script src="/static/app.js" defer></script>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
:root {
--bg: #fafafa;
--fg: #1f2328;
--muted: #57606a;
--accent: #0969da;
--accent-hover: #0860c7;
--border: #d0d7de;
--error-bg: #ffebe9;
--error-fg: #82071e;
--status-fg: #1f2328;
--code-bg: #f6f8fa;
--code-fg: #1f2328;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background-color: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 600;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
font-weight: 600;
}
.lede {
margin: 0 0 2rem;
color: var(--muted);
}
.upload-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background-color: #ffffff;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 1rem;
}
.file-label {
font-weight: 500;
}
input[type="file"] {
font: inherit;
flex: 1 1 240px;
min-width: 0;
}
button {
font: inherit;
font-weight: 500;
padding: 0.5rem 1rem;
background-color: var(--accent);
color: #ffffff;
border: 1px solid var(--accent);
border-radius: 6px;
cursor: pointer;
}
button:hover:not(:disabled) {
background-color: var(--accent-hover);
border-color: var(--accent-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.status {
margin: 0.5rem 0 0;
color: var(--status-fg);
min-height: 1.25rem;
font-size: 0.95rem;
}
.error {
margin: 0.5rem 0 1rem;
padding: 0.75rem 1rem;
background-color: var(--error-bg);
color: var(--error-fg);
border: 1px solid #ffcecb;
border-radius: 6px;
font-size: 0.95rem;
display: none;
white-space: pre-wrap;
}
.error:not(:empty) {
display: block;
}
.result {
margin-top: 1.5rem;
}
.result[hidden] {
display: none;
}
.rows-info {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 0.95rem;
}
.table-output {
margin: 0;
padding: 1rem;
background-color: var(--code-bg);
color: var(--code-fg);
border: 1px solid var(--border);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 0.85rem;
line-height: 1.4;
overflow-x: auto;
overflow-y: visible;
white-space: pre;
max-width: 100%;
}
@media (max-width: 600px) {
.container {
padding: 1.25rem 1rem 3rem;
}
h1 {
font-size: 1.5rem;
}
.upload-form {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
}
}
+10 -10
View File
@@ -4,23 +4,23 @@ go 1.26.4
require ( require (
github.com/biter777/countries v1.7.5 github.com/biter777/countries v1.7.5
github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/jedib0t/go-pretty/v6 v6.7.2
github.com/shopspring/decimal v1.6.0 github.com/shopspring/decimal v1.4.0
github.com/spf13/pflag v1.1.0 github.com/spf13/pflag v1.0.10
go.uber.org/mock v0.6.0 go.uber.org/mock v0.6.0
golang.org/x/time v0.16.0 golang.org/x/time v0.14.0
) )
require golang.org/x/sync v0.24.0 // indirect require golang.org/x/sync v0.18.0 // indirect
require ( require (
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nicksnyder/go-i18n/v2 v2.7.0 github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/mod v0.41.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.43.0 golang.org/x/text v0.23.0
golang.org/x/tools v0.51.0 // indirect golang.org/x/tools v0.36.0 // indirect
) )
tool go.uber.org/mock/mockgen tool go.uber.org/mock/mockgen
+22 -24
View File
@@ -1,43 +1,41 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q=
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E= github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ= github.com/jedib0t/go-pretty/v6 v6.7.2 h1:EYWgQNIH/+JsyHki7ns9OHyBKuHPkzrBo02uYjran7w=
github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jedib0t/go-pretty/v6 v6.7.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0= github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8= github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6 -19
View File
@@ -96,9 +96,11 @@ func (fs *JSONStore) Load(ctx context.Context) (map[string]*FillerQueue, error)
// the same directory followed by an atomic rename so a crash mid-write cannot // the same directory followed by an atomic rename so a crash mid-write cannot
// leave a half-written state file. // leave a half-written state file.
func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error { func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error {
queues := make(map[string][]persistedFiller) state := State{
latest := time.Time{} Version: StateVersion,
earliest := time.Time{} Platform: fs.platform,
Queues: make(map[string][]persistedFiller),
}
for symbol, q := range queue { for symbol, q := range queue {
if q == nil || q.Len() == 0 { if q == nil || q.Len() == 0 {
@@ -117,23 +119,8 @@ func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
Price: f.Price(), Price: f.Price(),
Filled: f.Filled(), Filled: f.Filled(),
}) })
if f.Timestamp().After(latest) {
latest = f.Timestamp()
}
if f.Timestamp().Before(earliest) || earliest.IsZero() {
earliest = f.Timestamp()
}
} }
queues[symbol] = persisted state.Queues[symbol] = persisted
}
state := State{
Version: StateVersion,
Platform: fs.platform,
Queues: queues,
Latest: latest,
Earliest: earliest,
} }
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
+5 -25
View File
@@ -9,7 +9,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/mocks" "github.com/nmoniz/any2anexoj/internal/mocks"
@@ -85,25 +84,13 @@ func TestJSONStore_RoundTrip(t *testing.T) {
// confirming the inline form is parseable guards against accidentally // confirming the inline form is parseable guards against accidentally
// double-encoding it again in the future). // double-encoding it again in the future).
var parsed struct { var parsed struct {
Latest time.Time `json:"latest"` Queues map[string][]struct {
Earliest time.Time `json:"earliest"`
Queues map[string][]struct {
RecordData json.RawMessage `json:"record_data"` RecordData json.RawMessage `json:"record_data"`
} `json:"queues"` } `json:"queues"`
} }
if err := json.Unmarshal(data, &parsed); err != nil { if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("saved state file is not valid JSON: %v", err) t.Fatalf("saved state file is not valid JSON: %v", err)
} }
// The latest/earliest bounds must be persisted alongside the queues.
// Every record in this test returns testRecordTime, so both bounds must
// equal it exactly.
if !parsed.Latest.Equal(testRecordTime) {
t.Errorf("state latest = %v; want %v", parsed.Latest, testRecordTime)
}
if !parsed.Earliest.Equal(testRecordTime) {
t.Errorf("state earliest = %v; want %v", parsed.Earliest, testRecordTime)
}
for symbol, lots := range parsed.Queues { for symbol, lots := range parsed.Queues {
for i, lot := range lots { for i, lot := range lots {
if len(lot.RecordData) == 0 { if len(lot.RecordData) == 0 {
@@ -354,20 +341,13 @@ func TestNewJSONStore_ValidatesArguments(t *testing.T) {
} }
} }
// testRecordTime is the deterministic timestamp returned by every record // newRecord builds a MockRecord whose Symbol() returns the given symbol.
// built in this file. Using a fixed value keeps the latest/earliest bounds // The JSONStore only reads Symbol() off the loaded Record during tests
// written by JSONStore.Save predictable and assertable. // (quantity/price/filled come from the persisted struct fields), so all
var testRecordTime = time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) // other Record methods can be left as default-mocked values.
// newRecord builds a MockRecord whose Symbol() returns the given symbol and
// whose Timestamp() returns testRecordTime. The JSONStore only reads Symbol()
// and Timestamp() off the Record during tests (quantity/price/filled come
// from the persisted struct fields), so all other Record methods can be left
// as default-mocked values.
func newRecord(ctrl *gomock.Controller, symbol string) *mocks.MockRecord { func newRecord(ctrl *gomock.Controller, symbol string) *mocks.MockRecord {
r := mocks.NewMockRecord(ctrl) r := mocks.NewMockRecord(ctrl)
r.EXPECT().Symbol().Return(symbol).AnyTimes() r.EXPECT().Symbol().Return(symbol).AnyTimes()
r.EXPECT().Timestamp().Return(testRecordTime).AnyTimes()
return r return r
} }
-3
View File
@@ -3,7 +3,6 @@ package internal
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"time"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@@ -16,8 +15,6 @@ const StateVersion = "2"
type State struct { type State struct {
Version string `json:"version"` Version string `json:"version"`
Platform string `json:"platform"` Platform string `json:"platform"`
Latest time.Time `json:"latest"`
Earliest time.Time `json:"earliest"`
Queues map[string][]persistedFiller `json:"queues"` Queues map[string][]persistedFiller `json:"queues"`
} }
-50
View File
@@ -1,50 +0,0 @@
# Trading212
Turns Trading212 account statements (CSV) into the internal record model used
to build Anexo J reports. This file records how statement events are
interpreted, and why.
## Spin-off events
A spin-off distributes shares of one company to the shareholders of another.
Trading212 reports it as an `Action` of `Spin off`:
```
Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,
```
These rows are booked as **buys with a cost of zero**: quantity and timestamp
are kept, and every cost column (price, result, total, stamp duty reserve tax,
currency conversion fee, French transaction tax) is required to be zero —
anything non-zero, or a non-positive quantity, is logged and returned as an
error, stopping the run. A row that surprises the reader must be looked at by
a human, not silently booked.
### Why cost zero is fair
No money was paid for the new shares, and their value came out of the value
of the parent shares — on the distribution date the parent's price drops by
roughly the distributed value. The position's total basis is unchanged; it is
just partitioned over two lots: the spin-off lot at `0` and the parent lot at
its original, unreduced basis. Selling the spin-off lot books full proceeds as
gain; selling the parent lot shows the offsetting smaller gain or bigger loss.
Netted together, the result equals the true economic gain.
Example — buy 100 parent shares for 10,000; a spin-off delivers new shares
worth 2,000; later sell the new shares for 2,200 and the parent for 8,100:
| Lot | Basis (cost 0) | Sold for | Gain (cost 0) | Gain (pro-rata) |
| ------ | -------------: | -------: | ------------: | --------------: |
| New | 0 | 2,200 | 2,200 | 200 |
| Parent | 10,000 | 8,100 | -1,900 | 100 |
| Total | | | **300** | **300** |
Either partition of the same 10,000 basis yields the same net gain of 300
(true gain: 10,300 received − 10,000 paid) — as long as gains and losses are
assessed together, as they are in Anexo J within the same year. If the two
lots are sold in different years the per-year attribution differs (the total
does not). Trading212 statements don't report a pro-rata basis allocation, so
cost 0 also keeps the report reproducible from the statement alone.
> [!WARNING]
> Verify any outputs of this tool on your own or with a certified accountant.
+69 -175
View File
@@ -92,10 +92,6 @@ const (
SpinOff = "spin off" SpinOff = "spin off"
) )
// ReadRecord reads the next record from the statement, dispatching each
// action to the parser that validates exactly the columns that matter for
// it. Split open/close rows are paired up here because the pairing spans
// rows: the ratio is only known once the close row is seen.
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
var splitRec *splitRecord var splitRec *splitRecord
@@ -105,53 +101,85 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
return Record{}, fmt.Errorf("read record: %w", err) return Record{}, fmt.Errorf("read record: %w", err)
} }
action := strings.Trim(strings.ToLower(raw[0]), " ") if strings.ToLower(raw[0]) == "action" {
if action == "action" { // header row
continue continue
} }
switch action { symbol := raw[2]
qant, err := parseDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
price, err := parseDecimal(raw[7])
if err != nil {
return Record{}, fmt.Errorf("parse record price: %w", err)
}
ts, err := parseTimestamp(raw[1])
if err != nil {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
conversionFee, err := parseOptionalDecimal(raw[16])
if err != nil {
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
}
stampDutyTax, err := parseOptionalDecimal(raw[14])
if err != nil {
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
}
frenchTxTax, err := parseOptionalDecimal(raw[18])
if err != nil {
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
}
var kind internal.Kind
switch strings.ToLower(raw[0]) {
case MarketBuy, LimitBuy: case MarketBuy, LimitBuy:
return rr.parseBuyRecord(ctx, raw) kind = internal.KindBuy
case MarketSell, LimitSell: case MarketSell, LimitSell:
return rr.parseSellRecord(ctx, raw) kind = internal.KindSell
case SpinOff:
return rr.parseSpinOff(ctx, raw)
case StockSplitOpen: case StockSplitOpen:
if splitRec != nil { if splitRec != nil {
return nil, fmt.Errorf("split already open") return nil, fmt.Errorf("split already open")
} }
rec, err := rr.parseTradeRecord(ctx, raw) splitRec = &splitRecord{
if err != nil { Record: Record{
return nil, err symbol: symbol,
kind: internal.KindSplit,
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
},
} }
rec.kind = internal.KindSplit
splitRec = &splitRecord{Record: rec}
continue continue
case StockSplitClose: case StockSplitClose:
if splitRec == nil { if splitRec == nil {
return nil, fmt.Errorf("missing split open") return nil, fmt.Errorf("missing split open")
} }
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
closeQty, err := parsePositiveDecimal(raw[6])
if err != nil {
return nil, fmt.Errorf("parse record quantity: %w", err)
}
splitRec.ratio = splitRec.Record.Quantity().Div(closeQty)
return splitRec, nil return splitRec, nil
case StokDistribution: case StokDistribution:
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
slog.Warn("Skipping 'stock distribution' event!", slog.Warn("Skipping 'stock distribution' event!",
slog.Any("ISIN", raw[2]), slog.Any("ISIN", symbol),
slog.Any("Quantity", qant),
)
continue
case SpinOff:
slog.Warn("Skipping 'spin off' event!",
slog.Any("ISIN", symbol),
slog.Any("Quantity", qant), slog.Any("Quantity", qant),
) )
continue continue
@@ -159,140 +187,20 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
default: default:
return Record{}, fmt.Errorf("parse record type: %s", raw[0]) return Record{}, fmt.Errorf("parse record type: %s", raw[0])
} }
return Record{
symbol: symbol,
kind: kind,
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
}, nil
} }
} }
// parseBuyRecord parses a market/limit buy row into a KindBuy Record.
func (rr RecordReader) parseBuyRecord(ctx context.Context, raw []string) (Record, error) {
rec, err := rr.parseTradeRecord(ctx, raw)
if err != nil {
return Record{}, err
}
rec.kind = internal.KindBuy
return rec, nil
}
// parseSellRecord parses a market/limit sell row into a KindSell Record.
func (rr RecordReader) parseSellRecord(ctx context.Context, raw []string) (Record, error) {
rec, err := rr.parseTradeRecord(ctx, raw)
if err != nil {
return Record{}, err
}
rec.kind = internal.KindSell
return rec, nil
}
// parseTradeRecord parses the columns shared by trade-shaped rows (quantity,
// price, timestamp, fees and taxes) into a Record with an unset Kind,
// leaving the kind decision to the caller. Split-open rows reuse it because
// their row shape is identical.
func (rr RecordReader) parseTradeRecord(ctx context.Context, raw []string) (Record, error) {
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
price, err := parseDecimal(raw[7])
if err != nil {
return Record{}, fmt.Errorf("parse record price: %w", err)
}
ts, err := parseTimestamp(raw[1])
if err != nil {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
conversionFee, err := parseOptionalDecimal(raw[16])
if err != nil {
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
}
stampDutyTax, err := parseOptionalDecimal(raw[14])
if err != nil {
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
}
frenchTxTax, err := parseOptionalDecimal(raw[18])
if err != nil {
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
}
return Record{
symbol: raw[2],
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
}, nil
}
// parseSpinOff books a 'Spin off' statement row as a zero-cost buy. The
// zero-cost interpretation is only sound when no money moved with the
// distribution — see README.md in this package for the rationale — so every
// cost column is parsed as optional (an empty column means zero) and
// required to be zero: anything unexpected is logged and returned as an
// error so the run stops instead of silently booking a wrong lot.
func (rr RecordReader) parseSpinOff(ctx context.Context, raw []string) (Record, error) {
symbol := raw[2]
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
ts, err := parseTimestamp(raw[1])
if err != nil {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
// A slice (not a map) keeps validation and error reporting deterministic.
// result and total are only ever read here, for this row kind.
costs := []struct {
name string
column int
}{
{"price", 7},
{"result", 10},
{"total", 12},
{"stamp duty reserve tax", 14},
{"currency conversion fee", 16},
{"french transaction tax", 18},
}
for _, cost := range costs {
value, err := parseOptionalDecimal(raw[cost.column])
if err != nil {
return Record{}, fmt.Errorf("parse record %s: %w", cost.name, err)
}
if !value.IsZero() {
reason := fmt.Errorf("spin off with a non-zero %s (%s) for ISIN %s: refusing to book it as a zero-cost buy",
cost.name, value, symbol)
slog.Error("Unexpected 'spin off' row!",
slog.Any("err", reason),
slog.Any("ISIN", symbol),
)
return Record{}, reason
}
}
slog.Info("Booking 'spin off' event as a zero-cost buy!",
slog.Any("ISIN", symbol),
slog.Any("Quantity", qant),
)
return Record{
symbol: symbol,
kind: internal.KindBuy,
quantity: qant,
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
}, nil
}
func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature { func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature {
return sync.OnceValue(func() internal.Nature { return sync.OnceValue(func() internal.Nature {
secType, err := of.SecurityTypeByISIN(ctx, isin) secType, err := of.SecurityTypeByISIN(ctx, isin)
@@ -330,20 +238,6 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) {
return parseDecimal(s) return parseDecimal(s)
} }
// parsePositiveDecimal parses s as a decimal and requires it to be strictly
// positive: empty, malformed, zero and negative values are rejected. A lot
// (or a split-ratio denominator) is meaningless without a positive quantity.
func parsePositiveDecimal(s string) (decimal.Decimal, error) {
value, err := parseDecimal(s)
if err != nil {
return decimal.Decimal{}, err
}
if !value.IsPositive() {
return decimal.Decimal{}, fmt.Errorf("non-positive value %q", s)
}
return value, nil
}
// parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped // parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped
// rows both without a timezone (treated here as UTC) and with an explicit // rows both without a timezone (treated here as UTC) and with an explicit
// numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker. // numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker.
+4 -63
View File
@@ -138,69 +138,6 @@ func TestRecordReader_ReadRecord(t *testing.T) {
r: bytes.NewBufferString(`Market buy,not-a-timestamp,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`), r: bytes.NewBufferString(`Market buy,not-a-timestamp,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{
name: "spin off booked as zero-cost buy",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`),
want: Record{
symbol: "XX1234567890",
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "0.0406450000"),
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: time.Date(2026, 2, 11, 13, 41, 43, 0, time.FixedZone("UTC", 0)),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
{
name: "spin off with a non-zero price is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,45.2300000000,USD,,,,0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero result is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,1.17995999,1.25,"EUR",0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero total is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,1.25,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero stamp duty reserve tax is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",0.25,"EUR",,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero currency conversion fee is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "spin off with a non-zero french transaction tax is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,0.1,"EUR"`),
wantErr: true,
},
{
name: "spin off with a non-positive quantity is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0E-10,0E-10,USD,,,,0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with an empty price column is booked as a zero-cost buy",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,,USD,,,,0.00,"EUR",,,,,,`),
want: Record{
symbol: "XX1234567890",
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "0.0406450000"),
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: time.Date(2026, 2, 11, 13, 41, 43, 0, time.FixedZone("UTC", 0)),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -440,6 +377,10 @@ func TestRecordReader_ReadRecord_SkipsCorporateActions(t *testing.T) {
name string name string
row string row string
}{ }{
{
name: "spin off",
row: `Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`,
},
{ {
name: "stock distribution", name: "stock distribution",
row: `Stock distribution,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`, row: `Stock distribution,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`,
-6
View File
@@ -1,6 +0,0 @@
@charset "UTF-8";/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
/*# sourceMappingURL=bootstrap.min.css.map */
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title> <title>{{.Title}}</title>
<style>{{.CSS}}</style> <link rel="stylesheet" href="{{.CSSURL}}" integrity="{{.Integrity}}" crossorigin="anonymous">
</head> </head>
<body> <body>
<main class="container-fluid py-4"> <main class="container-fluid py-4">
+10 -17
View File
@@ -2,9 +2,7 @@
// standalone HTML document: the report table fragment (built from the // standalone HTML document: the report table fragment (built from the
// shared table definition in internal/writing/internal/tabledef, so it can // shared table definition in internal/writing/internal/tabledef, so it can
// never drift from the terminal layout) embedded in a Bootstrap-styled page // never drift from the terminal layout) embedded in a Bootstrap-styled page
// with a translated title and generation timestamp. The official Bootstrap // with a translated title and generation timestamp.
// 5.3.3 stylesheet is inlined via go:embed, so the document renders fully
// offline.
package html package html
import ( import (
@@ -18,18 +16,11 @@ import (
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef" "github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
) )
// bootstrapMinCSS is the official Bootstrap 5.3.3 minified CSS distribution // bootstrapCSSURL is the jsDelivr CDN URL for Bootstrap 5.3.3.
// file, embedded so every rendered document renders fully offline. After any const bootstrapCSSURL = "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
// version bump, re-verify provenance with:
// // bootstrapIntegrity is the official SRI hash for Bootstrap 5.3.3 css.
// openssl dgst -sha384 -binary bootstrap.min.css | base64 const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
//
// It must print the official SRI hash published at
// https://getbootstrap.com/docs/5.3/getting-started/download/
// (sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH).
//
//go:embed bootstrap.min.css
var bootstrapMinCSS string
// Writer writes a complete HTML document for an AggregatorWriter. // Writer writes a complete HTML document for an AggregatorWriter.
type Writer struct { type Writer struct {
@@ -68,13 +59,15 @@ func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
data := struct { data := struct {
Lang string Lang string
Title string Title string
CSS template.CSS CSSURL string
Integrity string
TableFragment template.HTML TableFragment template.HTML
GeneratedAt string GeneratedAt string
}{ }{
Lang: hw.lang, Lang: hw.lang,
Title: title, Title: title,
CSS: template.CSS(bootstrapMinCSS), CSSURL: bootstrapCSSURL,
Integrity: bootstrapIntegrity,
TableFragment: template.HTML(fragment), TableFragment: template.HTML(fragment),
GeneratedAt: time.Now().UTC().Format(time.RFC3339), GeneratedAt: time.Now().UTC().Format(time.RFC3339),
} }
+3 -11
View File
@@ -77,8 +77,9 @@ func TestWriter_Render(t *testing.T) {
`<meta charset="utf-8">`, `<meta charset="utf-8">`,
`<meta name="viewport" content="width=device-width, initial-scale=1">`, `<meta name="viewport" content="width=device-width, initial-scale=1">`,
`<title>Tax report</title>`, `<title>Tax report</title>`,
`<style>`, `https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css`,
`Bootstrap v5.3.3`, `integrity="`,
`crossorigin="anonymous"`,
`<main class="container-fluid py-4">`, `<main class="container-fluid py-4">`,
`<h1>Tax report</h1>`, `<h1>Tax report</h1>`,
`<div class="table-responsive">`, `<div class="table-responsive">`,
@@ -104,15 +105,6 @@ func TestWriter_Render(t *testing.T) {
t.Errorf("expected raw <table> tag in output\nGot:\n%s", got) t.Errorf("expected raw <table> tag in output\nGot:\n%s", got)
} }
// The document must be fully self-contained: no external stylesheet
// <link> and no CDN reference anywhere.
if strings.Contains(got, "<link") {
t.Errorf("expected no external stylesheet <link>; document must be self-contained\nGot:\n%s", got)
}
if strings.Contains(got, "cdn.jsdelivr.net") {
t.Errorf("expected no CDN references; document must be self-contained\nGot:\n%s", got)
}
// Generated timestamp must be valid RFC3339 UTC. // Generated timestamp must be valid RFC3339 UTC.
startIdx := strings.Index(got, "Generated at ") startIdx := strings.Index(got, "Generated at ")
if startIdx == -1 { if startIdx == -1 {
-20
View File
@@ -1,20 +0,0 @@
{
"extends": [
"config:base",
":disableDependencyDashboard"
],
"packageRules": [
{
"updateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": false
}
],
"postUpdateOptions": [
"gomodTidy"
]
}