Compare commits
6
Commits
5c146ad60f
...
3dfc7bd6a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dfc7bd6a7 | ||
|
|
ebb471524e | ||
|
|
3b971cda61 | ||
|
|
bb00676452 | ||
|
|
4604175e8d | ||
|
|
d32f2b3a66 |
No files matched your search
@@ -23,6 +23,8 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
|
|||||||
cat statement.csv | any2anexoj-cli --platform=tranding212
|
cat statement.csv | any2anexoj-cli --platform=tranding212
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Incremental Reports
|
### Incremental Reports
|
||||||
|
|
||||||
For year over year reporting, pass `--state-file <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
|
For year over year reporting, pass `--state-file <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
|
||||||
@@ -53,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.
|
||||||
@@ -13,6 +13,10 @@ import (
|
|||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
"github.com/nmoniz/any2anexoj/internal/ofigi"
|
||||||
"github.com/nmoniz/any2anexoj/internal/trading212"
|
"github.com/nmoniz/any2anexoj/internal/trading212"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing/csv"
|
||||||
|
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
@@ -23,7 +27,7 @@ var (
|
|||||||
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
|
||||||
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 or csv")
|
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 rate api rate limits)")
|
||||||
selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry")
|
selectors = pflag.StringSlice("selectors", nil, "Only process entries that conform to all the selectors: code, assetCountry")
|
||||||
stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing")
|
stateFile = pflag.String("state-file", "", "Path to a state file for incremental processing")
|
||||||
@@ -98,16 +102,22 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
|
|||||||
|
|
||||||
switch *format {
|
switch *format {
|
||||||
case "csv":
|
case "csv":
|
||||||
return NewCSVWriter(stdout).Render(writer)
|
return csv.NewWriter(stdout).Render(writer)
|
||||||
case "table":
|
case "table":
|
||||||
loc, err := NewLocalizer(*lang)
|
loc, err := writing.NewLocalizer(*lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create localizer: %w", err)
|
return fmt.Errorf("create localizer: %w", err)
|
||||||
}
|
}
|
||||||
NewPrettyPrinter(stdout, loc).Render(writer)
|
table.NewPrinter(stdout, loc).Render(writer)
|
||||||
return nil
|
return nil
|
||||||
|
case "html":
|
||||||
|
loc, err := writing.NewLocalizer(*lang)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create localizer: %w", err)
|
||||||
|
}
|
||||||
|
return htmlwriter.NewWriter(stdout, loc, *lang).Render(writer)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported format %q: must be table or csv", *format)
|
return fmt.Errorf("unsupported format %q: must be table, csv or html", *format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,40 @@ func TestRunWithIO_NoStateFileByDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// TestRunWithIO_UnsupportedPlatformForPersistence verifies that using
|
||||||
// --state-file with an unknown platform surfaces a clear error rather than
|
// --state-file with an unknown platform surfaces a clear error rather than
|
||||||
// silently falling back to EphemeralStore.
|
// silently falling back to EphemeralStore.
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/biter777/countries"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/table"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/text"
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each
|
|
||||||
// ReportItem received.
|
|
||||||
type PrettyPrinter struct {
|
|
||||||
table table.Writer
|
|
||||||
output io.Writer
|
|
||||||
translator Translator
|
|
||||||
}
|
|
||||||
|
|
||||||
type Translator interface {
|
|
||||||
Translate(key string, count int, values map[string]any) string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter {
|
|
||||||
tw := table.NewWriter()
|
|
||||||
tw.SetOutputMirror(w)
|
|
||||||
tw.SetAutoIndex(true)
|
|
||||||
tw.SetStyle(table.StyleLight)
|
|
||||||
tw.SetColumnConfigs([]table.ColumnConfig{
|
|
||||||
colCountry(1),
|
|
||||||
colOther(2),
|
|
||||||
colOther(3),
|
|
||||||
colOther(4),
|
|
||||||
colOther(5),
|
|
||||||
colEuros(6),
|
|
||||||
colOther(7),
|
|
||||||
colOther(8),
|
|
||||||
colOther(9),
|
|
||||||
colEuros(10),
|
|
||||||
colEuros(11),
|
|
||||||
colEuros(12),
|
|
||||||
colCountry(13),
|
|
||||||
})
|
|
||||||
|
|
||||||
return &PrettyPrinter{
|
|
||||||
table: tw,
|
|
||||||
output: w,
|
|
||||||
translator: tr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) {
|
|
||||||
realizationTxt := pp.translator.Translate("realization", 1, nil)
|
|
||||||
acquisitionTxt := pp.translator.Translate("acquisition", 1, nil)
|
|
||||||
yearTxt := pp.translator.Translate("year", 1, nil)
|
|
||||||
monthTxt := pp.translator.Translate("month", 1, nil)
|
|
||||||
dayTxt := pp.translator.Translate("day", 1, nil)
|
|
||||||
valorTxt := pp.translator.Translate("value", 1, nil)
|
|
||||||
|
|
||||||
pp.table.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
|
|
||||||
pp.table.AppendHeader(table.Row{
|
|
||||||
pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil),
|
|
||||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
|
||||||
yearTxt, monthTxt, dayTxt, valorTxt,
|
|
||||||
pp.translator.Translate("expenses", 2, nil), pp.translator.Translate("foreign_tax_paid", 1, nil), pp.translator.Translate("counter_country", 1, nil),
|
|
||||||
})
|
|
||||||
|
|
||||||
for ri := range aw.Iter() {
|
|
||||||
pp.table.AppendRow(table.Row{
|
|
||||||
ri.AssetCountry, ri.Nature,
|
|
||||||
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
|
|
||||||
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
|
|
||||||
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pp.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
|
|
||||||
pp.table.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func colEuros(n int) table.ColumnConfig {
|
|
||||||
return table.ColumnConfig{
|
|
||||||
Number: n,
|
|
||||||
Align: text.AlignRight,
|
|
||||||
AlignFooter: text.AlignRight,
|
|
||||||
AlignHeader: text.AlignRight,
|
|
||||||
WidthMin: 12,
|
|
||||||
WidthMax: 15,
|
|
||||||
Transformer: func(val any) string {
|
|
||||||
return fmt.Sprintf("%v €", val)
|
|
||||||
},
|
|
||||||
TransformerFooter: func(val any) string {
|
|
||||||
return fmt.Sprintf("%v €", val)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func colOther(n int) table.ColumnConfig {
|
|
||||||
return table.ColumnConfig{
|
|
||||||
Number: n,
|
|
||||||
Align: text.AlignLeft,
|
|
||||||
AlignFooter: text.AlignLeft,
|
|
||||||
AlignHeader: text.AlignLeft,
|
|
||||||
WidthMax: 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func colCountry(n int) table.ColumnConfig {
|
|
||||||
return table.ColumnConfig{
|
|
||||||
Number: n,
|
|
||||||
Align: text.AlignLeft,
|
|
||||||
AlignFooter: text.AlignLeft,
|
|
||||||
AlignHeader: text.AlignLeft,
|
|
||||||
WidthMax: 24,
|
|
||||||
WidthMaxEnforcer: text.Trim,
|
|
||||||
Transformer: func(val any) string {
|
|
||||||
countryCode := val.(int64)
|
|
||||||
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,4 +1,6 @@
|
|||||||
package main
|
// Package csv renders an internal.AggregatorWriter as the CSV schema
|
||||||
|
// expected by Modelo 3 do Anexo J.
|
||||||
|
package csv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
@@ -8,15 +10,15 @@ import (
|
|||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CSVWriter struct {
|
type Writer struct {
|
||||||
w *csv.Writer
|
w *csv.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCSVWriter(w io.Writer) *CSVWriter {
|
func NewWriter(w io.Writer) *Writer {
|
||||||
return &CSVWriter{w: csv.NewWriter(w)}
|
return &Writer{w: csv.NewWriter(w)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cw *CSVWriter) Render(aw *internal.AggregatorWriter) error {
|
func (cw *Writer) Render(aw *internal.AggregatorWriter) error {
|
||||||
err := cw.w.Write([]string{
|
err := cw.w.Write([]string{
|
||||||
"source_country", "code",
|
"source_country", "code",
|
||||||
"realization_year", "realization_month", "realization_day", "realization_value",
|
"realization_year", "realization_month", "realization_day", "realization_value",
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package csv_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing/csv"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriter_Render(t *testing.T) {
|
||||||
|
aw := internal.NewAggregatorWriter()
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
items := []internal.ReportItem{
|
||||||
|
{
|
||||||
|
Symbol: "AAPL",
|
||||||
|
Nature: internal.NatureG01,
|
||||||
|
BrokerCountry: 826, // United Kingdom
|
||||||
|
AssetCountry: 840, // United States
|
||||||
|
BuyValue: decimal.NewFromFloat(100.50),
|
||||||
|
BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||||
|
SellValue: decimal.NewFromFloat(150.75),
|
||||||
|
SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
|
||||||
|
Fees: decimal.NewFromFloat(2.50),
|
||||||
|
Taxes: decimal.NewFromFloat(5.00),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Symbol: "VWRL",
|
||||||
|
Nature: internal.NatureG20,
|
||||||
|
BrokerCountry: 196, // Portugal equivalent demo
|
||||||
|
AssetCountry: 372, // Ireland
|
||||||
|
BuyValue: decimal.NewFromFloat(200.00),
|
||||||
|
BuyTimestamp: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
SellValue: decimal.NewFromFloat(250.25),
|
||||||
|
SellTimestamp: time.Date(2024, 7, 10, 0, 0, 0, 0, time.UTC),
|
||||||
|
Fees: decimal.NewFromFloat(1.00),
|
||||||
|
Taxes: decimal.NewFromFloat(0.00),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ri := range items {
|
||||||
|
if err := aw.Write(ctx, ri); err != nil {
|
||||||
|
t.Fatalf("write report item: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := csv.NewWriter(&buf).Render(aw); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
|
||||||
|
wantHeader := "source_country,code,realization_year,realization_month,realization_day,realization_value,acquisition_year,acquisition_month,acquisition_day,acquisition_value,expenses,foreign_tax_paid,counter_country"
|
||||||
|
if !strings.HasPrefix(out, wantHeader+"\n") {
|
||||||
|
t.Errorf("expected header row %q, got output:\n%s", wantHeader, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRow1 := "840,G01,2023,6,20,150.75,2023,1,15,100.50,2.50,5.00,826"
|
||||||
|
if !strings.Contains(out, wantRow1+"\n") {
|
||||||
|
t.Errorf("expected row 1 %q in output:\n%s", wantRow1, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRow2 := "372,G20,2024,7,10,250.25,2024,2,1,200.00,1.00,0.00,196"
|
||||||
|
if !strings.Contains(out, wantRow2+"\n") {
|
||||||
|
t.Errorf("expected row 2 %q in output:\n%s", wantRow2, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the header only appears once and we have exactly 1 header + 2 data rows.
|
||||||
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||||
|
if got, want := len(lines), 3; got != want {
|
||||||
|
t.Errorf("expected %d lines (1 header + 2 rows), got %d:\n%s", want, got, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{.Lang}}">
|
||||||
|
<head>
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{{.TableFragment}}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small">Generated at {{.GeneratedAt}}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jedib0t/go-pretty/v6/table"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||||
|
)
|
||||||
|
|
||||||
|
// htmlCSSClass is the CSS class list put on the rendered report table.
|
||||||
|
const htmlCSSClass = "table table-striped table-hover table-bordered"
|
||||||
|
|
||||||
|
// widthIndex is the weight of the auto-index (row number) column; the other
|
||||||
|
// column weights come from tabledef's width budgets.
|
||||||
|
const widthIndex = 6
|
||||||
|
|
||||||
|
// htmlColumnWeights lists one weight per rendered column, starting with the
|
||||||
|
// auto-index column, mirroring the column order of tabledef.Build so the
|
||||||
|
// HTML table keeps columns in the same strict proportion to each other as
|
||||||
|
// the terminal layout.
|
||||||
|
var htmlColumnWeights = []int{
|
||||||
|
widthIndex,
|
||||||
|
tabledef.WidthCountry, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros,
|
||||||
|
tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros,
|
||||||
|
tabledef.WidthEuros, tabledef.WidthEuros, tabledef.WidthCountry,
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTableFragment renders aw as the Bootstrap-styled HTML <table>
|
||||||
|
// fragment embedded in the document. The table gets a fixed layout with
|
||||||
|
// strictly proportional column widths, so the browser — not this package —
|
||||||
|
// decides when a header or cell wraps: on wide screens everything fits on
|
||||||
|
// one line, on narrow ones it wraps automatically.
|
||||||
|
func (hw *Writer) renderTableFragment(aw *internal.AggregatorWriter) string {
|
||||||
|
t := tabledef.Build(hw.translator, aw)
|
||||||
|
t.Style().HTML = table.HTMLOptions{
|
||||||
|
CSSClass: htmlCSSClass,
|
||||||
|
EscapeText: true,
|
||||||
|
Newline: "<br/>",
|
||||||
|
EmptyColumn: " ",
|
||||||
|
}
|
||||||
|
html := t.RenderHTML()
|
||||||
|
|
||||||
|
// go-pretty cannot emit table-level attributes or a <colgroup>, so splice
|
||||||
|
// them into its output: the fixed layout makes the colgroup's relative
|
||||||
|
// widths authoritative instead of content-driven column sizing.
|
||||||
|
openTag := `<table class="` + htmlCSSClass + `">`
|
||||||
|
fixedTag := `<table class="` + htmlCSSClass + `" style="table-layout: fixed">` + "\n" + colgroupHTML()
|
||||||
|
return strings.Replace(html, openTag, fixedTag, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// colgroupHTML returns a <colgroup> with one <col> per rendered column, each
|
||||||
|
// sized strictly in proportion to its weight. With the fixed table layout
|
||||||
|
// applied in renderTableFragment these widths are authoritative: wide
|
||||||
|
// viewports fit headers on a single line, narrow ones wrap automatically.
|
||||||
|
func colgroupHTML() string {
|
||||||
|
var total int
|
||||||
|
for _, w := range htmlColumnWeights {
|
||||||
|
total += w
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("<colgroup>")
|
||||||
|
for _, w := range htmlColumnWeights {
|
||||||
|
fmt.Fprintf(&sb, `<col style="width: %.2f%%">`, float64(w)/float64(total)*100)
|
||||||
|
}
|
||||||
|
sb.WriteString("</colgroup>")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package html_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
|
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWriter_RenderTableFragment renders a Portuguese document and asserts
|
||||||
|
// the embedded report table fragment carries the shared structure: the
|
||||||
|
// translated headers, the totals footer, and the strictly proportional
|
||||||
|
// colgroup that keeps HTML columns in the same relation to each other as
|
||||||
|
// the terminal layout.
|
||||||
|
func TestWriter_RenderTableFragment(t *testing.T) {
|
||||||
|
aw := buildTestAggregator(t)
|
||||||
|
|
||||||
|
localizer, err := writing.NewLocalizer("pt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create localizer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
hw := htmlwriter.NewWriter(&buf, localizer, "pt")
|
||||||
|
|
||||||
|
if err := hw.Render(aw); err != nil {
|
||||||
|
t.Fatalf("Render returned an error: %v", err)
|
||||||
|
}
|
||||||
|
got := buf.String()
|
||||||
|
|
||||||
|
wantSubstrings := []string{
|
||||||
|
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
|
||||||
|
`<colgroup>`,
|
||||||
|
`</colgroup>`,
|
||||||
|
`<col style="width: 3.03%">`,
|
||||||
|
`<col style="width: 12.12%">`,
|
||||||
|
`</table>`,
|
||||||
|
`<thead>`,
|
||||||
|
`</thead>`,
|
||||||
|
`<tbody>`,
|
||||||
|
`</tbody>`,
|
||||||
|
`<tfoot>`,
|
||||||
|
`</tfoot>`,
|
||||||
|
"Realização",
|
||||||
|
"Aquisição",
|
||||||
|
"Soma",
|
||||||
|
}
|
||||||
|
for _, s := range wantSubstrings {
|
||||||
|
if !strings.Contains(got, s) {
|
||||||
|
t.Errorf("rendered document missing expected substring %q\nGot:\n%s", s, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fragment must be embedded raw, not HTML-escaped.
|
||||||
|
if strings.Contains(got, "<table") {
|
||||||
|
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Package html renders an internal.AggregatorWriter as a complete,
|
||||||
|
// 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.
|
||||||
|
package html
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
// Writer writes a complete HTML document for an AggregatorWriter.
|
||||||
|
type Writer struct {
|
||||||
|
output io.Writer
|
||||||
|
translator tabledef.Translator
|
||||||
|
lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWriter builds a Writer that writes a complete HTML document to w. The
|
||||||
|
// report table is rendered from the shared table definition via tr, and the
|
||||||
|
// document title is looked up from the "report_title" translation via tr,
|
||||||
|
// so callers never pass a pre-baked title.
|
||||||
|
func NewWriter(w io.Writer, tr tabledef.Translator, lang string) *Writer {
|
||||||
|
return &Writer{
|
||||||
|
output: w,
|
||||||
|
translator: tr,
|
||||||
|
lang: lang,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed document.tmpl
|
||||||
|
var documentTemplateBody string
|
||||||
|
|
||||||
|
// documentTemplate renders the full HTML page from document.tmpl. The table
|
||||||
|
// fragment is typed as template.HTML so the template engine emits it
|
||||||
|
// verbatim without escaping the angle brackets in the <table> markup.
|
||||||
|
var documentTemplate = template.Must(template.New("html").Parse(documentTemplateBody))
|
||||||
|
|
||||||
|
// Render writes a complete standalone HTML document for aw to the configured
|
||||||
|
// output. The same Writer may be reused; each call rebuilds the table
|
||||||
|
// fragment from aw so the rendered <tr> count reflects aw's current items and
|
||||||
|
// no rows are duplicated across invocations.
|
||||||
|
func (hw *Writer) Render(aw *internal.AggregatorWriter) error {
|
||||||
|
fragment := hw.renderTableFragment(aw)
|
||||||
|
title := hw.translator.Translate("report_title", 1, nil)
|
||||||
|
data := struct {
|
||||||
|
Lang string
|
||||||
|
Title string
|
||||||
|
CSSURL string
|
||||||
|
Integrity string
|
||||||
|
TableFragment template.HTML
|
||||||
|
GeneratedAt string
|
||||||
|
}{
|
||||||
|
Lang: hw.lang,
|
||||||
|
Title: title,
|
||||||
|
CSSURL: bootstrapCSSURL,
|
||||||
|
Integrity: bootstrapIntegrity,
|
||||||
|
TableFragment: template.HTML(fragment),
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := documentTemplate.Execute(hw.output, data); err != nil {
|
||||||
|
return fmt.Errorf("render html document: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package html_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
|
htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildTestAggregator returns an AggregatorWriter seeded with two
|
||||||
|
// deterministic ReportItems so every test can assert on a stable document
|
||||||
|
// shape. Mirrors the fixture pattern used in
|
||||||
|
// internal/writing/table/printer_test.go.
|
||||||
|
func buildTestAggregator(t *testing.T) *internal.AggregatorWriter {
|
||||||
|
t.Helper()
|
||||||
|
aw := internal.NewAggregatorWriter()
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := aw.Write(ctx, internal.ReportItem{
|
||||||
|
Symbol: "AAPL",
|
||||||
|
Nature: internal.NatureG01,
|
||||||
|
BrokerCountry: 826,
|
||||||
|
AssetCountry: 840,
|
||||||
|
BuyValue: decimal.NewFromFloat(100.50),
|
||||||
|
BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
|
||||||
|
SellValue: decimal.NewFromFloat(150.75),
|
||||||
|
SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC),
|
||||||
|
Fees: decimal.NewFromFloat(2.50),
|
||||||
|
Taxes: decimal.NewFromFloat(5.00),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write first report item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = aw.Write(ctx, internal.ReportItem{
|
||||||
|
Symbol: "GOOGL",
|
||||||
|
Nature: internal.NatureG20,
|
||||||
|
BrokerCountry: 826,
|
||||||
|
AssetCountry: 840,
|
||||||
|
BuyValue: decimal.NewFromFloat(200.00),
|
||||||
|
BuyTimestamp: time.Date(2023, 3, 10, 0, 0, 0, 0, time.UTC),
|
||||||
|
SellValue: decimal.NewFromFloat(225.50),
|
||||||
|
SellTimestamp: time.Date(2023, 9, 5, 0, 0, 0, 0, time.UTC),
|
||||||
|
Fees: decimal.NewFromFloat(3.00),
|
||||||
|
Taxes: decimal.NewFromFloat(7.50),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write second report item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return aw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_Render(t *testing.T) {
|
||||||
|
loc, err := writing.NewLocalizer("en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create localizer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
hw := htmlwriter.NewWriter(&buf, loc, "en")
|
||||||
|
|
||||||
|
if err := hw.Render(buildTestAggregator(t)); err != nil {
|
||||||
|
t.Fatalf("Render returned an error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := buf.String()
|
||||||
|
|
||||||
|
wantSubstrings := []string{
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
`<html lang="en">`,
|
||||||
|
`<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"`,
|
||||||
|
`<main class="container-fluid py-4">`,
|
||||||
|
`<h1>Tax report</h1>`,
|
||||||
|
`<div class="table-responsive">`,
|
||||||
|
`<table class="table table-striped table-hover table-bordered" style="table-layout: fixed">`,
|
||||||
|
`<colgroup>`,
|
||||||
|
`</table>`,
|
||||||
|
`<tfoot>`,
|
||||||
|
`</tfoot>`,
|
||||||
|
`</html>`,
|
||||||
|
`<p class="text-muted small">Generated at `,
|
||||||
|
}
|
||||||
|
for _, s := range wantSubstrings {
|
||||||
|
if !strings.Contains(got, s) {
|
||||||
|
t.Errorf("rendered HTML missing expected substring %q\nGot:\n%s", s, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The table fragment must be embedded as raw HTML, not escaped.
|
||||||
|
if strings.Contains(got, "<table") {
|
||||||
|
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "<table") {
|
||||||
|
t.Errorf("expected raw <table> tag in output\nGot:\n%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated timestamp must be valid RFC3339 UTC.
|
||||||
|
startIdx := strings.Index(got, "Generated at ")
|
||||||
|
if startIdx == -1 {
|
||||||
|
t.Fatalf("missing 'Generated at' prefix in output")
|
||||||
|
}
|
||||||
|
timestampLine := got[startIdx:]
|
||||||
|
endIdx := strings.Index(timestampLine, "</p>")
|
||||||
|
if endIdx == -1 {
|
||||||
|
t.Fatalf("missing </p> closing tag after 'Generated at' line")
|
||||||
|
}
|
||||||
|
timestampStr := strings.TrimPrefix(strings.SplitN(timestampLine, "</p>", 2)[0], "Generated at ")
|
||||||
|
if _, err := time.Parse(time.RFC3339, timestampStr); err != nil {
|
||||||
|
t.Errorf("generation timestamp is not valid RFC3339: %q (err: %v)", timestampStr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriter_NoRowDuplication verifies that calling Render twice on the
|
||||||
|
// same writer produces documents with the same number of <tr> rows, just
|
||||||
|
// like table.Printer's no-duplication guarantee.
|
||||||
|
func TestWriter_NoRowDuplication(t *testing.T) {
|
||||||
|
loc, err := writing.NewLocalizer("en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create localizer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
hw := htmlwriter.NewWriter(&buf, loc, "en")
|
||||||
|
|
||||||
|
aw := buildTestAggregator(t)
|
||||||
|
|
||||||
|
if err := hw.Render(aw); err != nil {
|
||||||
|
t.Fatalf("first Render returned an error: %v", err)
|
||||||
|
}
|
||||||
|
first := buf.String()
|
||||||
|
buf.Reset()
|
||||||
|
|
||||||
|
if err := hw.Render(aw); err != nil {
|
||||||
|
t.Fatalf("second Render returned an error: %v", err)
|
||||||
|
}
|
||||||
|
second := buf.String()
|
||||||
|
|
||||||
|
firstRows := strings.Count(first, "<tr")
|
||||||
|
secondRows := strings.Count(second, "<tr")
|
||||||
|
if firstRows == 0 {
|
||||||
|
t.Fatalf("expected at least one <tr> row in the first render; got:\n%s", first)
|
||||||
|
}
|
||||||
|
if firstRows != secondRows {
|
||||||
|
t.Errorf("expected second Render to produce the same number of <tr> rows as the first (no duplication)\nfirst rows=%d, second rows=%d\nFirst:\n%s\nSecond:\n%s",
|
||||||
|
firstRows, secondRows, first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_LangAttribute(t *testing.T) {
|
||||||
|
loc, err := writing.NewLocalizer("pt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create localizer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
hw := htmlwriter.NewWriter(&buf, loc, "pt")
|
||||||
|
|
||||||
|
if err := hw.Render(buildTestAggregator(t)); err != nil {
|
||||||
|
t.Fatalf("Render returned an error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := buf.String()
|
||||||
|
if !strings.Contains(got, `<html lang="pt">`) {
|
||||||
|
t.Errorf("expected <html lang=\"pt\"> attribute; got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `<h1>Relatório fiscal</h1>`) {
|
||||||
|
t.Errorf("expected Portuguese title in <h1>; got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Package tabledef holds the single definition of the Anexo J report table
|
||||||
|
// — column configuration, translated headers, data rows and the totals
|
||||||
|
// footer — shared by every renderer under internal/writing. Living in a
|
||||||
|
// writing-internal package lets the table and html packages project the
|
||||||
|
// same structure into their own formats without any of them exposing
|
||||||
|
// go-pretty types in their public API.
|
||||||
|
package tabledef
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/biter777/countries"
|
||||||
|
"github.com/jedib0t/go-pretty/v6/table"
|
||||||
|
"github.com/jedib0t/go-pretty/v6/text"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Translator is the contract the report table renderers rely on for
|
||||||
|
// human-readable column labels. *writing.Localizer satisfies it.
|
||||||
|
type Translator interface {
|
||||||
|
Translate(key string, count int, values map[string]any) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column width budgets enforced by the terminal renderer. The html package
|
||||||
|
// derives its proportional column widths from the same values, keeping both
|
||||||
|
// renderings in the same strict proportion to each other.
|
||||||
|
const (
|
||||||
|
// WidthCountry is the width budget of a country column ("code - name").
|
||||||
|
WidthCountry = 24
|
||||||
|
// WidthOther is the width budget of a plain column (code, year, ...).
|
||||||
|
WidthOther = 12
|
||||||
|
// WidthEuros is the width budget of a money column ("123.45 €").
|
||||||
|
WidthEuros = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build constructs a fully configured go-pretty table.Writer from the items
|
||||||
|
// in aw: one column per report field, translated headers, data rows and the
|
||||||
|
// totals footer. Each call returns a fresh table so callers can render the
|
||||||
|
// same data multiple times without duplicating rows, and the returned table
|
||||||
|
// is not wired to any output.
|
||||||
|
//
|
||||||
|
// Change columns, headers or footers here so every renderer stays in sync.
|
||||||
|
func Build(tr Translator, aw *internal.AggregatorWriter) table.Writer {
|
||||||
|
tw := table.NewWriter()
|
||||||
|
tw.SetAutoIndex(true)
|
||||||
|
tw.SetStyle(table.StyleLight)
|
||||||
|
tw.SetColumnConfigs([]table.ColumnConfig{
|
||||||
|
colCountry(1),
|
||||||
|
colOther(2),
|
||||||
|
colOther(3),
|
||||||
|
colOther(4),
|
||||||
|
colOther(5),
|
||||||
|
colEuros(6),
|
||||||
|
colOther(7),
|
||||||
|
colOther(8),
|
||||||
|
colOther(9),
|
||||||
|
colEuros(10),
|
||||||
|
colEuros(11),
|
||||||
|
colEuros(12),
|
||||||
|
colCountry(13),
|
||||||
|
})
|
||||||
|
|
||||||
|
realizationTxt := tr.Translate("realization", 1, nil)
|
||||||
|
acquisitionTxt := tr.Translate("acquisition", 1, nil)
|
||||||
|
yearTxt := tr.Translate("year", 1, nil)
|
||||||
|
monthTxt := tr.Translate("month", 1, nil)
|
||||||
|
dayTxt := tr.Translate("day", 1, nil)
|
||||||
|
valorTxt := tr.Translate("value", 1, nil)
|
||||||
|
|
||||||
|
tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true})
|
||||||
|
tw.AppendHeader(table.Row{
|
||||||
|
tr.Translate("source_country", 1, nil), tr.Translate("code", 1, nil),
|
||||||
|
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||||
|
yearTxt, monthTxt, dayTxt, valorTxt,
|
||||||
|
tr.Translate("expenses", 2, nil), tr.Translate("foreign_tax_paid", 1, nil), tr.Translate("counter_country", 1, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
for ri := range aw.Iter() {
|
||||||
|
tw.AppendRow(table.Row{
|
||||||
|
ri.AssetCountry, ri.Nature,
|
||||||
|
ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2),
|
||||||
|
ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2),
|
||||||
|
ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sumTxt := tr.Translate("sum", 1, nil)
|
||||||
|
// The footer lists one cell per column (13) so it never relies on
|
||||||
|
// go-pretty's implicit short-row padding; the trailing empty cell is the
|
||||||
|
// counter-country column, which has no total. Totals are formatted with
|
||||||
|
// StringFixed(2) so they match the two-decimal data rows.
|
||||||
|
tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned().StringFixed(2), "", "", "", aw.TotalSpent().StringFixed(2), aw.TotalFees().StringFixed(2), aw.TotalTaxes().StringFixed(2), ""}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
|
||||||
|
|
||||||
|
return tw
|
||||||
|
}
|
||||||
|
|
||||||
|
func colEuros(n int) table.ColumnConfig {
|
||||||
|
return table.ColumnConfig{
|
||||||
|
Number: n,
|
||||||
|
Align: text.AlignRight,
|
||||||
|
AlignFooter: text.AlignRight,
|
||||||
|
AlignHeader: text.AlignRight,
|
||||||
|
WidthMin: WidthOther,
|
||||||
|
WidthMax: WidthEuros,
|
||||||
|
WidthMaxEnforcer: truncateHeader,
|
||||||
|
Transformer: func(val any) string {
|
||||||
|
return fmt.Sprintf("%v €", val)
|
||||||
|
},
|
||||||
|
TransformerFooter: func(val any) string {
|
||||||
|
return fmt.Sprintf("%v €", val)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func colOther(n int) table.ColumnConfig {
|
||||||
|
return table.ColumnConfig{
|
||||||
|
Number: n,
|
||||||
|
Align: text.AlignLeft,
|
||||||
|
AlignFooter: text.AlignLeft,
|
||||||
|
AlignHeader: text.AlignLeft,
|
||||||
|
WidthMax: WidthOther,
|
||||||
|
WidthMaxEnforcer: truncateHeader,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func colCountry(n int) table.ColumnConfig {
|
||||||
|
return table.ColumnConfig{
|
||||||
|
Number: n,
|
||||||
|
Align: text.AlignLeft,
|
||||||
|
AlignFooter: text.AlignLeft,
|
||||||
|
AlignHeader: text.AlignLeft,
|
||||||
|
WidthMax: WidthCountry,
|
||||||
|
WidthMaxEnforcer: text.Trim,
|
||||||
|
Transformer: func(val any) string {
|
||||||
|
countryCode := val.(int64)
|
||||||
|
return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateHeader enforces a column's WidthMax on terminal output by cutting
|
||||||
|
// the text exactly at the width limit — mid-word if that is where it lands —
|
||||||
|
// and marking the cut with an ellipsis: keeping as many leading runes
|
||||||
|
// readable beats a tidy word boundary. All indexing is rune-based so
|
||||||
|
// multibyte labels (e.g. Portuguese) truncate correctly.
|
||||||
|
func truncateHeader(val string, width int) string {
|
||||||
|
runes := []rune(val)
|
||||||
|
if len(runes) <= width {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
// leave room for the ellipsis
|
||||||
|
limit := max(width-1, 1)
|
||||||
|
return string(runes[:limit]) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package tabledef
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTruncateHeader(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
val string
|
||||||
|
width int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "fits unchanged", val: "TAX PAID ABROAD", width: 15, want: "TAX PAID ABROAD"},
|
||||||
|
{name: "cut mid-word keeps the most runes", val: "EXPENSES AND CHARGES", width: 15, want: "EXPENSES AND C…"},
|
||||||
|
{name: "single overlong word is hard cut", val: "ESTRANGEIRO", width: 8, want: "ESTRANG…"},
|
||||||
|
{name: "cut ignores word boundaries", val: "IMPOSTO PAGO NO ESTRANGEIRO", width: 15, want: "IMPOSTO PAGO N…"},
|
||||||
|
{name: "multibyte runes count as one", val: "PAÍS DA CONTRAPARTE", width: 12, want: "PAÍS DA CON…"},
|
||||||
|
{name: "narrow width", val: "ABCD", width: 2, want: "A…"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := truncateHeader(tt.val, tt.width); got != tt.want {
|
||||||
|
t.Errorf("%s: truncateHeader(%q, %d) = %q, want %q", tt.name, tt.val, tt.width, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package writing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package main
|
package writing_test
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNewLocalizer(t *testing.T) {
|
func TestNewLocalizer(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -15,7 +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) {
|
||||||
_, err := NewLocalizer(tt.lang)
|
_, err := writing.NewLocalizer(tt.lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("want success call but failed: %v", err)
|
t.Fatalf("want success call but failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Package table renders an internal.AggregatorWriter as the human-readable
|
||||||
|
// Anexo J report table for the terminal. The shared table definition it (and
|
||||||
|
// the html package) renders from lives in internal/writing/internal/tabledef.
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Printer writes a simple, human readable, table to the provided io.Writer
|
||||||
|
// for the report items in an AggregatorWriter. Each Render call builds a
|
||||||
|
// fresh table from the aggregator, so invoking it repeatedly on the same
|
||||||
|
// instance never duplicates rows.
|
||||||
|
type Printer struct {
|
||||||
|
output io.Writer
|
||||||
|
translator tabledef.Translator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPrinter(w io.Writer, tr tabledef.Translator) *Printer {
|
||||||
|
return &Printer{
|
||||||
|
output: w,
|
||||||
|
translator: tr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render writes the terminal table for aw to the configured output.
|
||||||
|
func (pp *Printer) Render(aw *internal.AggregatorWriter) {
|
||||||
|
t := tabledef.Build(pp.translator, aw)
|
||||||
|
t.SetOutputMirror(pp.output)
|
||||||
|
t.Render()
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package table_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nmoniz/any2anexoj/internal"
|
"github.com/nmoniz/any2anexoj/internal"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing"
|
||||||
|
"github.com/nmoniz/any2anexoj/internal/writing/table"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPrettyPrinter_Render(t *testing.T) {
|
func TestPrinter_Render(t *testing.T) {
|
||||||
// Create test data
|
// Create test data
|
||||||
aw := internal.NewAggregatorWriter()
|
aw := internal.NewAggregatorWriter()
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -48,14 +50,14 @@ func TestPrettyPrinter_Render(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create English localizer
|
// Create English localizer
|
||||||
localizer, err := NewLocalizer("en")
|
localizer, err := writing.NewLocalizer("en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create localizer: %v", err)
|
t.Fatalf("failed to create localizer: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create pretty printer with buffer
|
// Create writing printer with buffer
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
pp := NewPrettyPrinter(&buf, localizer)
|
pp := table.NewPrinter(&buf, localizer)
|
||||||
|
|
||||||
// Render the table
|
// Render the table
|
||||||
pp.Render(aw)
|
pp.Render(aw)
|
||||||
@@ -66,18 +68,17 @@ func TestPrettyPrinter_Render(t *testing.T) {
|
|||||||
// Expected output
|
// Expected output
|
||||||
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
|
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
|
||||||
│ │ │ REALIZATION │ ACQUISITION │ │
|
│ │ │ REALIZATION │ ACQUISITION │ │
|
||||||
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND CH │ TAX PAID ABROAD │ COUNTER COUNTRY │
|
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND C… │ TAX PAID ABROAD │ COUNTER COUNTRY │
|
||||||
│ │ │ │ │ │ │ │ │ │ │ │ ARGES │ │ │
|
|
||||||
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
|
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
|
||||||
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
|
│ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │
|
||||||
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
|
│ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │
|
||||||
├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
|
├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
|
||||||
│ │ SUM │ 376.25 € │ │ 300.5 € │ 5.5 € │ 12.5 € │ │
|
│ │ SUM │ 376.25 € │ │ 300.50 € │ 5.50 € │ 12.50 € │ │
|
||||||
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
|
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
|
||||||
`
|
`
|
||||||
|
|
||||||
// Compare output
|
// Compare output
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
|
t.Errorf("Printer.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,5 +42,13 @@
|
|||||||
"foreign_tax_paid": {
|
"foreign_tax_paid": {
|
||||||
"one": "Tax paid abroad",
|
"one": "Tax paid abroad",
|
||||||
"other": "Taxes paid abroad"
|
"other": "Taxes paid abroad"
|
||||||
|
},
|
||||||
|
"sum": {
|
||||||
|
"one": "Sum",
|
||||||
|
"other": "Sums"
|
||||||
|
},
|
||||||
|
"report_title": {
|
||||||
|
"one": "Tax report",
|
||||||
|
"other": "Tax reports"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,5 +42,13 @@
|
|||||||
"foreign_tax_paid": {
|
"foreign_tax_paid": {
|
||||||
"one": "Imposto pago no estrangeiro",
|
"one": "Imposto pago no estrangeiro",
|
||||||
"other": "Impostos pagos no estrangeiro"
|
"other": "Impostos pagos no estrangeiro"
|
||||||
|
},
|
||||||
|
"sum": {
|
||||||
|
"one": "Soma",
|
||||||
|
"other": "Somas"
|
||||||
|
},
|
||||||
|
"report_title": {
|
||||||
|
"one": "Relatório fiscal",
|
||||||
|
"other": "Relatórios fiscais"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user