Author SHA1 Message Date
renovate-bot Bot aa7aef44f7 Update module github.com/spf13/pflag to v1.1.0
Generate check / check-changes (pull_request) Successful in 25s
Generate check / verify-generate (pull_request) Skipped
Quality / check-changes (pull_request) Successful in 3s
Quality / static-checks (pull_request) Successful in 54s
Quality / run-tests (pull_request) Successful in 55s
2026-11-07 00:01:31 +00:00
renovate-bot Bot ede5dfafe2 Update module github.com/nicksnyder/go-i18n/v2 to v2.7.0 (#42)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #42
Co-authored-by: Renovate Bot <[email protected]>
2026-10-30 17:09:12 +00:00
renovate-bot Bot ae0e77cecb Update module github.com/shopspring/decimal to v1.6.0 (#40)
Badges / coveralls (push) Successful in 1m23s
Reviewed-on: #40
Co-authored-by: Renovate Bot <[email protected]>
2026-10-30 07:13:01 +00:00
renovate-bot Bot 76502bc8d4 Update module golang.org/x/time to v0.16.0 (#43)
Badges / coveralls (push) Successful in 1m2s
Reviewed-on: #43
Co-authored-by: Renovate Bot <[email protected]>
2026-10-29 15:46:53 +00:00
renovate-bot Bot 6a399b7c56 Update module golang.org/x/text to v0.43.0 (#41)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #41
Co-authored-by: Renovate Bot <[email protected]>
2026-10-28 17:18:34 +00:00
renovate-bot Bot ff791955d4 Update module github.com/nicksnyder/go-i18n/v2 to v2.6.1 (#37)
Badges / coveralls (push) Successful in 58s
Reviewed-on: #37
Co-authored-by: Renovate Bot <[email protected]>
2026-10-27 23:00:10 +00:00
renovate-bot Bot 5011174bdd Update module github.com/jedib0t/go-pretty/v6 to v6.8.3 (#38)
Badges / coveralls (push) Successful in 1m1s
Co-authored-by: Renovate Bot <[email protected]>
2026-10-27 22:55:50 +00:00
natercio c42a852938 Run go mod tidy after renovate-bot updates deps (#39)
Badges / coveralls (push) Successful in 27s
Reviewed-on: #39
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-27 22:49:07 +00:00
natercio f35cf1d624 Setup renovate (#36)
Badges / coveralls (push) Successful in 29s
Reviewed-on: #36
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-27 22:32:38 +00:00
natercio 16ae9975ee Handle Spin-off events as buys (#35)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #35
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-27 22:08:18 +00:00
natercio 41b287c804 Include latest and earliest record times in state (#34)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #34
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-12 23:09:39 +01:00
natercio 47f38fce2b Implement HTML writer (#33)
Badges / coveralls (push) Successful in 1m1s
Reviewed-on: #33
Co-authored-by: Natercio Moniz <[email protected]>
2026-10-05 16:25:21 +01:00
20 changed files with 408 additions and 745 deletions

No files matched your search

+8 -39
View File
@@ -20,10 +20,16 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
## Usage
```bash
cat statement.csv | any2anexoj-cli --platform=tranding212
cat statement.csv | any2anexoj-cli --platform=trading212
```
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.
`trading212` is currently the only supported platform. The output format is selected with `-f`/`--format`:
- `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
@@ -55,40 +61,3 @@ 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.
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.
## 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")
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
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 rate api rate limits)")
ofAPIKey = pflag.String("open-figi-api-key", "", "An OpenFIGI API key for faster report generation (better api rate limits)")
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")
)
-202
View File
@@ -1,202 +0,0 @@
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
@@ -1,89 +0,0 @@
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
@@ -1,108 +0,0 @@
(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
@@ -1,33 +0,0 @@
<!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
@@ -1,168 +0,0 @@
: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 (
github.com/biter777/countries v1.7.5
github.com/jedib0t/go-pretty/v6 v6.7.2
github.com/shopspring/decimal v1.4.0
github.com/spf13/pflag v1.0.10
github.com/jedib0t/go-pretty/v6 v6.8.3
github.com/shopspring/decimal v1.6.0
github.com/spf13/pflag v1.1.0
go.uber.org/mock v0.6.0
golang.org/x/time v0.14.0
golang.org/x/time v0.16.0
)
require golang.org/x/sync v0.18.0 // indirect
require golang.org/x/sync v0.24.0 // indirect
require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/nicksnyder/go-i18n/v2 v2.7.0
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.23.0
golang.org/x/tools v0.36.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.43.0
golang.org/x/tools v0.51.0 // indirect
)
tool go.uber.org/mock/mockgen
+24 -22
View File
@@ -1,41 +1,43 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.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/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jedib0t/go-pretty/v6 v6.7.2 h1:EYWgQNIH/+JsyHki7ns9OHyBKuHPkzrBo02uYjran7w=
github.com/jedib0t/go-pretty/v6 v6.7.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ=
github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
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/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0=
github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8=
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/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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+19 -6
View File
@@ -96,11 +96,9 @@ 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
// leave a half-written state file.
func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error {
state := State{
Version: StateVersion,
Platform: fs.platform,
Queues: make(map[string][]persistedFiller),
}
queues := make(map[string][]persistedFiller)
latest := time.Time{}
earliest := time.Time{}
for symbol, q := range queue {
if q == nil || q.Len() == 0 {
@@ -119,8 +117,23 @@ func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
Price: f.Price(),
Filled: f.Filled(),
})
if f.Timestamp().After(latest) {
latest = f.Timestamp()
}
state.Queues[symbol] = persisted
if f.Timestamp().Before(earliest) || earliest.IsZero() {
earliest = f.Timestamp()
}
}
queues[symbol] = persisted
}
state := State{
Version: StateVersion,
Platform: fs.platform,
Queues: queues,
Latest: latest,
Earliest: earliest,
}
buf := &bytes.Buffer{}
+24 -4
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/mocks"
@@ -84,6 +85,8 @@ func TestJSONStore_RoundTrip(t *testing.T) {
// confirming the inline form is parseable guards against accidentally
// double-encoding it again in the future).
var parsed struct {
Latest time.Time `json:"latest"`
Earliest time.Time `json:"earliest"`
Queues map[string][]struct {
RecordData json.RawMessage `json:"record_data"`
} `json:"queues"`
@@ -91,6 +94,16 @@ func TestJSONStore_RoundTrip(t *testing.T) {
if err := json.Unmarshal(data, &parsed); err != nil {
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 i, lot := range lots {
if len(lot.RecordData) == 0 {
@@ -341,13 +354,20 @@ func TestNewJSONStore_ValidatesArguments(t *testing.T) {
}
}
// newRecord builds a MockRecord whose Symbol() returns the given symbol.
// The JSONStore only reads Symbol() off the loaded Record during tests
// (quantity/price/filled come from the persisted struct fields), so all
// other Record methods can be left as default-mocked values.
// testRecordTime is the deterministic timestamp returned by every record
// built in this file. Using a fixed value keeps the latest/earliest bounds
// written by JSONStore.Save predictable and assertable.
var testRecordTime = time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
// 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 {
r := mocks.NewMockRecord(ctrl)
r.EXPECT().Symbol().Return(symbol).AnyTimes()
r.EXPECT().Timestamp().Return(testRecordTime).AnyTimes()
return r
}
+3
View File
@@ -3,6 +3,7 @@ package internal
import (
"context"
"encoding/json"
"time"
"github.com/shopspring/decimal"
)
@@ -15,6 +16,8 @@ const StateVersion = "2"
type State struct {
Version string `json:"version"`
Platform string `json:"platform"`
Latest time.Time `json:"latest"`
Earliest time.Time `json:"earliest"`
Queues map[string][]persistedFiller `json:"queues"`
}
+50
View File
@@ -0,0 +1,50 @@
# 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.
+151 -45
View File
@@ -92,6 +92,10 @@ const (
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) {
var splitRec *splitRecord
@@ -101,13 +105,89 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
return Record{}, fmt.Errorf("read record: %w", err)
}
if strings.ToLower(raw[0]) == "action" {
action := strings.Trim(strings.ToLower(raw[0]), " ")
if action == "action" { // header row
continue
}
symbol := raw[2]
switch action {
case MarketBuy, LimitBuy:
return rr.parseBuyRecord(ctx, raw)
qant, err := parseDecimal(raw[6])
case MarketSell, LimitSell:
return rr.parseSellRecord(ctx, raw)
case SpinOff:
return rr.parseSpinOff(ctx, raw)
case StockSplitOpen:
if splitRec != nil {
return nil, fmt.Errorf("split already open")
}
rec, err := rr.parseTradeRecord(ctx, raw)
if err != nil {
return nil, err
}
rec.kind = internal.KindSplit
splitRec = &splitRecord{Record: rec}
continue
case StockSplitClose:
if splitRec == nil {
return nil, fmt.Errorf("missing split open")
}
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
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.Any("ISIN", raw[2]),
slog.Any("Quantity", qant),
)
continue
default:
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
}
}
}
// 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)
}
@@ -137,69 +217,81 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
}
var kind internal.Kind
switch strings.ToLower(raw[0]) {
case MarketBuy, LimitBuy:
kind = internal.KindBuy
case MarketSell, LimitSell:
kind = internal.KindSell
case StockSplitOpen:
if splitRec != nil {
return nil, fmt.Errorf("split already open")
}
splitRec = &splitRecord{
Record: Record{
symbol: symbol,
kind: internal.KindSplit,
return Record{
symbol: raw[2],
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
},
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
}, nil
}
continue
// 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]
case StockSplitClose:
if splitRec == nil {
return nil, fmt.Errorf("missing split open")
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
return splitRec, nil
case StokDistribution:
slog.Warn("Skipping 'stock distribution' event!",
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),
)
continue
case SpinOff:
slog.Warn("Skipping 'spin off' event!",
slog.Any("ISIN", symbol),
slog.Any("Quantity", qant),
)
continue
default:
return Record{}, fmt.Errorf("parse record type: %s", raw[0])
}
return Record{
symbol: symbol,
kind: kind,
kind: internal.KindBuy,
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
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 {
return sync.OnceValue(func() internal.Nature {
@@ -238,6 +330,20 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) {
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
// 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.
+63 -4
View File
@@ -138,6 +138,69 @@ 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",,`),
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 {
t.Run(tt.name, func(t *testing.T) {
@@ -377,10 +440,6 @@ func TestRecordReader_ReadRecord_SkipsCorporateActions(t *testing.T) {
name 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",
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
@@ -0,0 +1,6 @@
@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 name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.CSSURL}}" integrity="{{.Integrity}}" crossorigin="anonymous">
<style>{{.CSS}}</style>
</head>
<body>
<main class="container-fluid py-4">
+17 -10
View File
@@ -2,7 +2,9 @@
// standalone HTML document: the report table fragment (built from the
// shared table definition in internal/writing/internal/tabledef, so it can
// never drift from the terminal layout) embedded in a Bootstrap-styled page
// with a translated title and generation timestamp.
// with a translated title and generation timestamp. The official Bootstrap
// 5.3.3 stylesheet is inlined via go:embed, so the document renders fully
// offline.
package html
import (
@@ -16,11 +18,18 @@ import (
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
)
// bootstrapCSSURL is the jsDelivr CDN URL for Bootstrap 5.3.3.
const bootstrapCSSURL = "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
// bootstrapIntegrity is the official SRI hash for Bootstrap 5.3.3 css.
const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
// bootstrapMinCSS is the official Bootstrap 5.3.3 minified CSS distribution
// file, embedded so every rendered document renders fully offline. After any
// version bump, re-verify provenance with:
//
// openssl dgst -sha384 -binary bootstrap.min.css | base64
//
// 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.
type Writer struct {
@@ -59,15 +68,13 @@ func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
data := struct {
Lang string
Title string
CSSURL string
Integrity string
CSS template.CSS
TableFragment template.HTML
GeneratedAt string
}{
Lang: hw.lang,
Title: title,
CSSURL: bootstrapCSSURL,
Integrity: bootstrapIntegrity,
CSS: template.CSS(bootstrapMinCSS),
TableFragment: template.HTML(fragment),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
+11 -3
View File
@@ -77,9 +77,8 @@ func TestWriter_Render(t *testing.T) {
`<meta charset="utf-8">`,
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
`<title>Tax report</title>`,
`https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css`,
`integrity="`,
`crossorigin="anonymous"`,
`<style>`,
`Bootstrap v5.3.3`,
`<main class="container-fluid py-4">`,
`<h1>Tax report</h1>`,
`<div class="table-responsive">`,
@@ -105,6 +104,15 @@ func TestWriter_Render(t *testing.T) {
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.
startIdx := strings.Index(got, "Generated at ")
if startIdx == -1 {
+20
View File
@@ -0,0 +1,20 @@
{
"extends": [
"config:base",
":disableDependencyDashboard"
],
"packageRules": [
{
"updateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": false
}
],
"postUpdateOptions": [
"gomodTidy"
]
}