Author SHA1 Message Date
natercio 5c146ad60f better domain isolation
Generate check / check-changes (pull_request) Successful in 2s
Generate check / verify-generate (pull_request) Skipped
Quality / check-changes (pull_request) Successful in 2s
Quality / static-checks (pull_request) Failing after 13s
Quality / run-tests (pull_request) Failing after 54s
2026-10-05 15:08:26 +01:00
natercio e167627ac3 addressed consistency 2026-10-05 15:08:19 +01:00
natercio 6703481dbb print as html 2026-10-05 15:08:00 +01:00
natercio 62fe1a7076 writers in writing package 2026-10-05 15:07:42 +01:00
19 changed files with 802 additions and 140 deletions

No files matched your search

+2
View File
@@ -23,6 +23,8 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
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
For year over year reporting, pass `--state-file <path>` to keep the FIFO buy-queue between runs. The typical workflow is:
+15 -5
View File
@@ -13,6 +13,10 @@ import (
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/ofigi"
"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"
"golang.org/x/text/language"
)
@@ -23,7 +27,7 @@ var (
platform = pflag.StringP("platform", "p", "trading212", "One of the supported platforms")
lang = pflag.StringP("language", "l", language.Portuguese.String(), "The 2 letter language code")
debug = pflag.BoolP("debug", "d", false, "Activate to log debug messages")
format = pflag.StringP("format", "f", "table", "Output format: table 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)")
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")
@@ -98,16 +102,22 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
switch *format {
case "csv":
return NewCSVWriter(stdout).Render(writer)
return csv.NewWriter(stdout).Render(writer)
case "table":
loc, err := NewLocalizer(*lang)
loc, err := writing.NewLocalizer(*lang)
if err != nil {
return fmt.Errorf("create localizer: %w", err)
}
NewPrettyPrinter(stdout, loc).Render(writer)
table.NewPrinter(stdout, loc).Render(writer)
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:
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)
}
}
+34
View File
@@ -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
// --state-file with an unknown platform surfaces a clear error rather than
// silently falling back to EphemeralStore.
-122
View File
@@ -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)
},
}
}
@@ -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 (
"encoding/csv"
@@ -8,15 +10,15 @@ import (
"github.com/nmoniz/any2anexoj/internal"
)
type CSVWriter struct {
type Writer struct {
w *csv.Writer
}
func NewCSVWriter(w io.Writer) *CSVWriter {
return &CSVWriter{w: csv.NewWriter(w)}
func NewWriter(w io.Writer) *Writer {
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{
"source_country", "code",
"realization_year", "realization_month", "realization_day", "realization_value",
+78
View File
@@ -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)
}
}
+18
View File
@@ -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>
+69
View File
@@ -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: "&nbsp;",
}
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()
}
+60
View File
@@ -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, "&lt;table") {
t.Errorf("table fragment appears to be HTML-escaped; expected raw <table> markup\nGot:\n%s", got)
}
}
+78
View File
@@ -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
}
+180
View File
@@ -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, "&lt;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)
}
}
+154
View File
@@ -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,8 @@
<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer.go
package main
========
package writing
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer.go
import (
"embed"
@@ -1,6 +1,16 @@
<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer_test.go
package main
import "testing"
========
package writing_test
import (
"testing"
"github.com/nmoniz/any2anexoj/internal/writing"
)
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer_test.go
func TestNewLocalizer(t *testing.T) {
tests := []struct {
@@ -15,7 +25,11 @@ func TestNewLocalizer(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer_test.go
_, err := NewLocalizer(tt.lang)
========
_, err := writing.NewLocalizer(tt.lang)
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer_test.go
if err != nil {
t.Fatalf("want success call but failed: %v", err)
}
+34
View File
@@ -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 (
"bytes"
@@ -6,10 +6,14 @@ import (
"time"
"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/table"
"github.com/shopspring/decimal"
)
func TestPrettyPrinter_Render(t *testing.T) {
func TestPrinter_Render(t *testing.T) {
// Create test data
aw := internal.NewAggregatorWriter()
ctx := t.Context()
@@ -48,14 +52,18 @@ func TestPrettyPrinter_Render(t *testing.T) {
}
// Create English localizer
<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go
localizer, err := NewLocalizer("en")
========
localizer, err := writing.NewLocalizer("en")
>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go
if err != nil {
t.Fatalf("failed to create localizer: %v", err)
}
// Create pretty printer with buffer
// Create writing printer with buffer
var buf bytes.Buffer
pp := NewPrettyPrinter(&buf, localizer)
pp := table.NewPrinter(&buf, localizer)
// Render the table
pp.Render(aw)
@@ -66,18 +74,17 @@ func TestPrettyPrinter_Render(t *testing.T) {
// Expected output
want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐
│ │ │ REALIZATION │ ACQUISITION │ │
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND CH │ TAX PAID ABROAD │ COUNTER COUNTRY │
│ │ │ │ │ │ │ │ │ │ │ │ ARGES │ │ │
│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND C… │ TAX PAID ABROAD │ COUNTER COUNTRY │
├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ 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 │
├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤
│ │ SUM │ 376.25 € │ │ 300.5 € │ 5.5 € │ 12.5 € │ │
│ │ SUM │ 376.25 € │ │ 300.50 € │ 5.50 € │ 12.50 € │ │
└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘
`
// Compare output
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": {
"one": "Tax 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": {
"one": "Imposto pago no estrangeiro",
"other": "Impostos pagos no estrangeiro"
},
"sum": {
"one": "Soma",
"other": "Somas"
},
"report_title": {
"one": "Relatório fiscal",
"other": "Relatórios fiscais"
}
}