Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dfc7bd6a7 | ||
|
|
ebb471524e | ||
|
|
3b971cda61 | ||
|
|
bb00676452 | ||
|
|
4604175e8d | ||
|
|
d32f2b3a66 |
No files matched your search
@@ -55,3 +55,40 @@ State is only persisted once the input reaches EOF, so a crash or `Ctrl-C` befor
|
|||||||
All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision.
|
All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision.
|
||||||
There are no explicit rules or details about how to round Euro values in Anexo J.
|
There are no explicit rules or details about how to round Euro values in Anexo J.
|
||||||
This application rounds according to `Portaria n.º 1180/2001, art. 2.º, alínea c) e d)` (Ministerial Order / Government Order) examples, which imply we should round to the 2nd decimal place by rounding up (ceiling) or down (floor) depending on whether the third decimal place is ≥ 5 or < 5, respectively.
|
This application rounds according to `Portaria n.º 1180/2001, art. 2.º, alínea c) e d)` (Ministerial Order / Government Order) examples, which imply we should round to the 2nd decimal place by rounding up (ceiling) or down (floor) depending on whether the third decimal place is ≥ 5 or < 5, respectively.
|
||||||
|
|
||||||
|
## Web app
|
||||||
|
|
||||||
|
A minimal single-user web frontend is bundled in the `cmd/any2anexoj-web` binary. It reuses the existing `internal/trading212` parser, the `internal.BuildReport` pipeline, and the `internal/prettyprinter` formatter, so the rendered table is byte-for-byte identical to the CLI output.
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/any2anexoj-web
|
||||||
|
```
|
||||||
|
|
||||||
|
The server listens on `:8080` by default. Override the port with the `PORT` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=9000 go run ./cmd/any2anexoj-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open <http://localhost:8080> in a browser, pick a Trading212 CSV file, and click **Upload**. The rendered Anexo J table appears below the form along with the row count.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
The web app exposes two JSON endpoints. Auth is intentionally stubbed (a no-op middleware), and state is kept in process memory only — single user, no persistence.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|----------------|---------------------------------------------------------------------------------------------------|
|
||||||
|
| POST | `/api/upload` | `multipart/form-data` with a `csv` file field. Returns `{"table": "<rendered>", "rows": <int>}`. |
|
||||||
|
| GET | `/api/table` | Returns the most recently rendered table (`{"table": "", "rows": 0}` if none uploaded yet). |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -F csv=@trading212-small.csv http://localhost:8080/api/upload
|
||||||
|
curl http://localhost:8080/api/table
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The web app is single-user and in-memory. Uploads overwrite the previously stored table, and restarting the server clears all state. Auth is a stub — do not expose this service to the public internet without adding real authentication and per-user state first.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/web"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
slog.Error("any2anexoj-web: fatal error", slog.Any("err", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("shutdown gracefully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// run wires up the server, installs signal-driven cancellation, and blocks on
|
||||||
|
// the HTTP server until it stops. It is split out from main so tests can drive
|
||||||
|
// the same code paths if needed.
|
||||||
|
func run() error {
|
||||||
|
addr := os.Getenv("PORT")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8080"
|
||||||
|
}
|
||||||
|
// A bare "8080" value is friendlier than the net.Listen-style ":8080".
|
||||||
|
if addr[0] != ':' {
|
||||||
|
addr = ":" + addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-mount the embedded FS so the static subtree is rooted at "" inside
|
||||||
|
// the web package (matches /static/...).
|
||||||
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
state := web.NewState()
|
||||||
|
srv, err := web.NewServer(state, staticSub)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: srv.Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
eg, ctx := errgroup.WithContext(ctx)
|
||||||
|
|
||||||
|
eg.Go(func() error {
|
||||||
|
slog.Info("listening for http", slog.Any("port", addr))
|
||||||
|
err := httpServer.ListenAndServe()
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
eg.Go(func() error {
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return eg.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var UPLOAD_URL = "/api/upload";
|
||||||
|
|
||||||
|
function $(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
var status = $("status");
|
||||||
|
status.textContent = message || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setError(message) {
|
||||||
|
var error = $("error");
|
||||||
|
error.textContent = message || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResult() {
|
||||||
|
var result = $("result");
|
||||||
|
var rowsInfo = $("rows-info");
|
||||||
|
var tableOutput = $("table-output");
|
||||||
|
rowsInfo.textContent = "";
|
||||||
|
tableOutput.textContent = "";
|
||||||
|
result.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(rows, table) {
|
||||||
|
var result = $("result");
|
||||||
|
var rowsInfo = $("rows-info");
|
||||||
|
var tableOutput = $("table-output");
|
||||||
|
rowsInfo.textContent = "Rows: " + rows;
|
||||||
|
tableOutput.textContent = table || "";
|
||||||
|
result.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append("csv", file);
|
||||||
|
|
||||||
|
return fetch(UPLOAD_URL, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().then(function (data) {
|
||||||
|
return { ok: response.ok, status: response.status, data: data };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
setError("");
|
||||||
|
clearResult();
|
||||||
|
|
||||||
|
var fileInput = $("csv");
|
||||||
|
if (!fileInput.files || fileInput.files.length === 0) {
|
||||||
|
setError("Please choose a CSV file before uploading.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = fileInput.files[0];
|
||||||
|
var button = $("upload-button");
|
||||||
|
button.disabled = true;
|
||||||
|
setStatus("Uploading " + file.name + "...");
|
||||||
|
|
||||||
|
uploadFile(file).then(function (result) {
|
||||||
|
button.disabled = false;
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
setStatus("");
|
||||||
|
var serverMsg = result.data && result.data.error ? result.data.error : "";
|
||||||
|
var msg = "Upload failed (HTTP " + result.status + ").";
|
||||||
|
if (serverMsg) {
|
||||||
|
msg += " " + serverMsg;
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data && typeof result.data.error === "string" && result.data.error.length > 0) {
|
||||||
|
setStatus("");
|
||||||
|
setError(result.data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = result.data && typeof result.data.table === "string" ? result.data.table : "";
|
||||||
|
var rows = result.data && typeof result.data.rows === "number" ? result.data.rows : 0;
|
||||||
|
|
||||||
|
setStatus("Upload complete.");
|
||||||
|
showResult(rows, table);
|
||||||
|
}).catch(function (err) {
|
||||||
|
button.disabled = false;
|
||||||
|
setStatus("");
|
||||||
|
var msg = err && err.message ? err.message : String(err);
|
||||||
|
setError("Network error while uploading: " + msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var form = $("upload-form");
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", handleSubmit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>any2anexoj web</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<h1>any2anexoj web</h1>
|
||||||
|
<p class="lede">Upload a Trading212-style CSV and view the rendered tax report.</p>
|
||||||
|
|
||||||
|
<form id="upload-form" class="upload-form" novalidate>
|
||||||
|
<label class="file-label" for="csv">Choose CSV file</label>
|
||||||
|
<input type="file" id="csv" name="csv" accept=".csv,text/csv">
|
||||||
|
|
||||||
|
<button type="submit" id="upload-button">Upload</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="status" class="status" role="status" aria-live="polite"></div>
|
||||||
|
<div id="error" class="error" role="alert" aria-live="assertive"></div>
|
||||||
|
|
||||||
|
<section id="result" class="result" hidden>
|
||||||
|
<h2 id="result-heading">Result</h2>
|
||||||
|
<p id="rows-info" class="rows-info"></p>
|
||||||
|
<pre id="table-output" class="table-output"></pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #fafafa;
|
||||||
|
--fg: #1f2328;
|
||||||
|
--muted: #57606a;
|
||||||
|
--accent: #0969da;
|
||||||
|
--accent-hover: #0860c7;
|
||||||
|
--border: #d0d7de;
|
||||||
|
--error-bg: #ffebe9;
|
||||||
|
--error-fg: #82071e;
|
||||||
|
--status-fg: #1f2328;
|
||||||
|
--code-bg: #f6f8fa;
|
||||||
|
--code-fg: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
font: inherit;
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: var(--accent-hover);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: var(--status-fg);
|
||||||
|
min-height: 1.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: var(--error-bg);
|
||||||
|
color: var(--error-fg);
|
||||||
|
border: 1px solid #ffcecb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error:not(:empty) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows-info {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
color: var(--code-fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
white-space: pre;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer.go
|
|
||||||
package main
|
|
||||||
========
|
|
||||||
package writing
|
package writing
|
||||||
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer.go
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer_test.go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
========
|
|
||||||
package writing_test
|
package writing_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
)
|
)
|
||||||
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer_test.go
|
|
||||||
|
|
||||||
func TestNewLocalizer(t *testing.T) {
|
func TestNewLocalizer(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -25,11 +19,7 @@ func TestNewLocalizer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer_test.go
|
|
||||||
_, err := NewLocalizer(tt.lang)
|
|
||||||
========
|
|
||||||
_, err := writing.NewLocalizer(tt.lang)
|
_, err := writing.NewLocalizer(tt.lang)
|
||||||
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer_test.go
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("want success call but failed: %v", err)
|
t.Fatalf("want success call but failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go
|
|
||||||
========
|
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing"
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
@@ -52,11 +50,7 @@ func TestPrinter_Render(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create English localizer
|
// Create English localizer
|
||||||
<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go
|
|
||||||
localizer, err := NewLocalizer("en")
|
|
||||||
========
|
|
||||||
localizer, err := writing.NewLocalizer("en")
|
localizer, err := writing.NewLocalizer("en")
|
||||||
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create localizer: %v", err)
|
t.Fatalf("failed to create localizer: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user