Compare commits
12
Commits
3dfc7bd6a7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03eaab4d04 | ||
|
|
ede5dfafe2 | ||
|
|
ae0e77cecb | ||
|
|
76502bc8d4 | ||
|
|
6a399b7c56 | ||
|
|
ff791955d4 | ||
|
|
5011174bdd | ||
|
|
c42a852938 | ||
|
|
f35cf1d624 | ||
|
|
16ae9975ee | ||
|
|
41b287c804 | ||
|
|
47f38fce2b |
No files matched your search
@@ -20,10 +20,16 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```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
|
### 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.
|
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.
|
|
||||||
@@ -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 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")
|
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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -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>
|
|
||||||
@@ -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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.7.2
|
github.com/jedib0t/go-pretty/v6 v6.8.3
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.6.0
|
||||||
github.com/spf13/pflag v1.0.10
|
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.14.0
|
golang.org/x/time v0.16.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sync v0.18.0 // indirect
|
require golang.org/x/sync v0.25.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.6.0
|
github.com/nicksnyder/go-i18n/v2 v2.7.0
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
golang.org/x/mod v0.27.0 // indirect
|
golang.org/x/mod v0.42.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
golang.org/x/text v0.23.0
|
golang.org/x/text v0.44.0
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
golang.org/x/tools v0.52.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool go.uber.org/mock/mockgen
|
tool go.uber.org/mock/mockgen
|
||||||
@@ -1,41 +1,43 @@
|
|||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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 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.7.2 h1:EYWgQNIH/+JsyHki7ns9OHyBKuHPkzrBo02uYjran7w=
|
github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ=
|
||||||
github.com/jedib0t/go-pretty/v6 v6.7.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
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 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.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
|
github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
|
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 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.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/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=
|
||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||||
|
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||||
|
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||||
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=
|
||||||
+19
-6
@@ -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
|
// 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 {
|
||||||
state := State{
|
queues := make(map[string][]persistedFiller)
|
||||||
Version: StateVersion,
|
latest := time.Time{}
|
||||||
Platform: fs.platform,
|
earliest := time.Time{}
|
||||||
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 {
|
||||||
@@ -119,8 +117,23 @@ 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()
|
||||||
}
|
}
|
||||||
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{}
|
buf := &bytes.Buffer{}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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"
|
||||||
@@ -84,6 +85,8 @@ 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"`
|
||||||
|
Earliest time.Time `json:"earliest"`
|
||||||
Queues map[string][]struct {
|
Queues map[string][]struct {
|
||||||
RecordData json.RawMessage `json:"record_data"`
|
RecordData json.RawMessage `json:"record_data"`
|
||||||
} `json:"queues"`
|
} `json:"queues"`
|
||||||
@@ -91,6 +94,16 @@ func TestJSONStore_RoundTrip(t *testing.T) {
|
|||||||
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 {
|
||||||
@@ -341,13 +354,20 @@ func TestNewJSONStore_ValidatesArguments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRecord builds a MockRecord whose Symbol() returns the given symbol.
|
// testRecordTime is the deterministic timestamp returned by every record
|
||||||
// The JSONStore only reads Symbol() off the loaded Record during tests
|
// built in this file. Using a fixed value keeps the latest/earliest bounds
|
||||||
// (quantity/price/filled come from the persisted struct fields), so all
|
// written by JSONStore.Save predictable and assertable.
|
||||||
// other Record methods can be left as default-mocked values.
|
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 {
|
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,6 +3,7 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
@@ -15,6 +16,8 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -92,6 +92,10 @@ 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
|
||||||
|
|
||||||
@@ -101,13 +105,89 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ToLower(raw[0]) == "action" {
|
action := strings.Trim(strings.ToLower(raw[0]), " ")
|
||||||
|
if action == "action" { // header row
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
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)
|
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var kind internal.Kind
|
return Record{
|
||||||
switch strings.ToLower(raw[0]) {
|
symbol: raw[2],
|
||||||
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,
|
|
||||||
quantity: qant,
|
quantity: qant,
|
||||||
price: price,
|
price: price,
|
||||||
fees: conversionFee,
|
fees: conversionFee,
|
||||||
taxes: stampDutyTax.Add(frenchTxTax),
|
taxes: stampDutyTax.Add(frenchTxTax),
|
||||||
timestamp: ts,
|
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:
|
qant, err := parsePositiveDecimal(raw[6])
|
||||||
if splitRec == nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("missing split open")
|
return Record{}, fmt.Errorf("parse record quantity: %w", err)
|
||||||
}
|
}
|
||||||
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
|
|
||||||
return splitRec, nil
|
|
||||||
|
|
||||||
case StokDistribution:
|
ts, err := parseTimestamp(raw[1])
|
||||||
slog.Warn("Skipping 'stock distribution' event!",
|
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("ISIN", symbol),
|
||||||
slog.Any("Quantity", qant),
|
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{
|
return Record{
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
kind: kind,
|
kind: internal.KindBuy,
|
||||||
quantity: qant,
|
quantity: qant,
|
||||||
price: price,
|
price: decimal.Zero,
|
||||||
fees: conversionFee,
|
fees: decimal.Zero,
|
||||||
taxes: stampDutyTax.Add(frenchTxTax),
|
taxes: decimal.Zero,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
|
||||||
}, nil
|
}, 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 {
|
||||||
@@ -238,6 +330,20 @@ 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.
|
||||||
|
|||||||
@@ -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",,`),
|
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) {
|
||||||
@@ -377,10 +440,6 @@ 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
@@ -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 */
|
||||||
@@ -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>
|
||||||
<link rel="stylesheet" href="{{.CSSURL}}" integrity="{{.Integrity}}" crossorigin="anonymous">
|
<style>{{.CSS}}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="container-fluid py-4">
|
<main class="container-fluid py-4">
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
// 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.
|
// 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
|
package html
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,11 +18,18 @@ import (
|
|||||||
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bootstrapCSSURL is the jsDelivr CDN URL for Bootstrap 5.3.3.
|
// bootstrapMinCSS is the official Bootstrap 5.3.3 minified CSS distribution
|
||||||
const bootstrapCSSURL = "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
|
// file, embedded so every rendered document renders fully offline. After any
|
||||||
|
// version bump, re-verify provenance with:
|
||||||
// bootstrapIntegrity is the official SRI hash for Bootstrap 5.3.3 css.
|
//
|
||||||
const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
// 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.
|
// Writer writes a complete HTML document for an AggregatorWriter.
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
@@ -59,15 +68,13 @@ func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
|
|||||||
data := struct {
|
data := struct {
|
||||||
Lang string
|
Lang string
|
||||||
Title string
|
Title string
|
||||||
CSSURL string
|
CSS template.CSS
|
||||||
Integrity string
|
|
||||||
TableFragment template.HTML
|
TableFragment template.HTML
|
||||||
GeneratedAt string
|
GeneratedAt string
|
||||||
}{
|
}{
|
||||||
Lang: hw.lang,
|
Lang: hw.lang,
|
||||||
Title: title,
|
Title: title,
|
||||||
CSSURL: bootstrapCSSURL,
|
CSS: template.CSS(bootstrapMinCSS),
|
||||||
Integrity: bootstrapIntegrity,
|
|
||||||
TableFragment: template.HTML(fragment),
|
TableFragment: template.HTML(fragment),
|
||||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,8 @@ 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>`,
|
||||||
`https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css`,
|
`<style>`,
|
||||||
`integrity="`,
|
`Bootstrap v5.3.3`,
|
||||||
`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">`,
|
||||||
@@ -105,6 +104,15 @@ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"config:base",
|
||||||
|
":disableDependencyDashboard"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"updateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch",
|
||||||
|
"pin",
|
||||||
|
"digest"
|
||||||
|
],
|
||||||
|
"automerge": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"postUpdateOptions": [
|
||||||
|
"gomodTidy"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user