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

No files matched your search

+9 -1
View File
@@ -20,9 +20,17 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
## Usage ## Usage
```bash ```bash
cat statement.csv | any2anexoj-cli --platform=tranding212 cat statement.csv | any2anexoj-cli --platform=trading212
``` ```
`trading212` is currently the only supported platform. The output format is selected with `-f`/`--format`:
- `table` (default): pretty terminal table
- `csv`: the Anexo J CSV schema
- `html`: a complete, standalone Bootstrap-styled HTML report — a `<!DOCTYPE html>` document with the Anexo J table embedded and the official Bootstrap 5.3.3 stylesheet inlined via `go:embed`, so the report renders fully offline
The `table` and `html` formats are translated according to `--language`/`-l` (`pt` by default, `en` available). Other flags: `--selectors` filters which entries are processed (`code`, `assetCountry`), `--open-figi-api-key` raises OpenFIGI rate limits for faster report generation, and `--debug` enables debug logging.
### Incremental Reports ### Incremental Reports
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:
+16 -6
View File
@@ -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,8 +27,8 @@ 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 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)
} }
} }
-168
View File
@@ -1,168 +0,0 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/pflag"
)
// resetFlags puts every pflag-backed variable back to its default so each
// test that calls runWithIO sees a clean slate regardless of test ordering
// or arguments the previous test set via pflag.Set.
func resetFlags(t *testing.T) {
t.Helper()
if err := pflag.Set("platform", "trading212"); err != nil {
t.Fatalf("reset platform flag: %v", err)
}
if err := pflag.Set("language", "en"); err != nil {
t.Fatalf("reset language flag: %v", err)
}
if err := pflag.Set("debug", "false"); err != nil {
t.Fatalf("reset debug flag: %v", err)
}
if err := pflag.Set("format", "table"); err != nil {
t.Fatalf("reset format flag: %v", err)
}
if err := pflag.Set("open-figi-api-key", ""); err != nil {
t.Fatalf("reset open-figi-api-key flag: %v", err)
}
if err := pflag.Set("selectors", ""); err != nil {
t.Fatalf("reset selectors flag: %v", err)
}
if err := pflag.Set("state-file", ""); err != nil {
t.Fatalf("reset state-file flag: %v", err)
}
}
// trading212SampleCSV is a minimal Trading212 export with a header row and
// one market buy + one matching market sell so BuildReport reaches EOF and
// exercises the store.Save path. The line format mirrors the fixtures in
// internal/trading212/record_test.go (20 columns).
const trading212SampleCSV = `Action,Time,ISIN,Ticker,Name,Notes,Quantity,Price,Price currency,Exchange rate,Result,Result currency,Charges,Charges currency,Stamp duty,Stamp duty currency,Conversion fee,Conversion fee currency,French transaction tax,French transaction tax currency
Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,
Market sell,2025-08-04 11:45:30,XX1234567890,ABXY,"Asparagus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"
`
// runWithStdin runs runWithIO against the supplied stdin payload.
func runWithStdin(t *testing.T, stdin string, stdout *bytes.Buffer) error {
t.Helper()
if err := runWithIO(t.Context(), strings.NewReader(stdin), stdout); err != nil {
return err
}
return nil
}
// TestRunWithIO_StateFileCreated verifies that running the CLI with
// --state-file produces a state file on disk after a successful EOF.
func TestRunWithIO_StateFileCreated(t *testing.T) {
resetFlags(t)
t.Cleanup(func() { resetFlags(t) })
dir := t.TempDir()
statePath := filepath.Join(dir, "state.json")
if err := pflag.Set("state-file", statePath); err != nil {
t.Fatalf("set state-file flag: %v", err)
}
if err := pflag.Set("format", "csv"); err != nil {
t.Fatalf("set format flag: %v", err)
}
var stdout bytes.Buffer
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
}
info, err := os.Stat(statePath)
if err != nil {
t.Fatalf("expected state file at %s but stat returned error: %v", statePath, err)
}
if info.Size() == 0 {
t.Fatalf("state file at %s is empty", statePath)
}
// State file must look like JSON with the expected version field.
body, err := os.ReadFile(statePath)
if err != nil {
t.Fatalf("read state file: %v", err)
}
if !bytes.Contains(body, []byte(`"version"`)) {
t.Errorf("state file missing version field, got: %s", body)
}
if !bytes.Contains(body, []byte(`"trading212"`)) {
t.Errorf("state file missing trading212 platform, got: %s", body)
}
}
// TestRunWithIO_NoStateFileByDefault verifies that omitting --state-file
// behaves exactly like the pre-persistence CLI: nothing is written to disk
// and the report still renders.
func TestRunWithIO_NoStateFileByDefault(t *testing.T) {
resetFlags(t)
t.Cleanup(func() { resetFlags(t) })
// Use a temp working directory so any accidental file write would
// show up clearly via t.TempDir's cleanup listing.
dir := t.TempDir()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := pflag.Set("format", "csv"); err != nil {
t.Fatalf("set format flag: %v", err)
}
var stdout bytes.Buffer
if err := runWithStdin(t, trading212SampleCSV, &stdout); err != nil {
t.Fatalf("runWithIO returned an error: %v\nstdout: %s", err, stdout.String())
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read tmp dir: %v", err)
}
for _, e := range entries {
t.Errorf("unexpected file written without --state-file: %s", e.Name())
}
if stdout.Len() == 0 {
t.Errorf("expected non-empty csv output on stdout")
}
}
// TestRunWithIO_UnsupportedPlatformForPersistence verifies that using
// --state-file with an unknown platform surfaces a clear error rather than
// silently falling back to EphemeralStore.
func TestRunWithIO_UnsupportedPlatformForPersistence(t *testing.T) {
resetFlags(t)
t.Cleanup(func() { resetFlags(t) })
if err := pflag.Set("state-file", filepath.Join(t.TempDir(), "state.json")); err != nil {
t.Fatalf("set state-file flag: %v", err)
}
// Currently only trading212 is wired through buildStore, but the
// reader switch also only supports trading212, so the reader error
// fires first. Either error is acceptable; we just need a clear
// failure message.
if err := pflag.Set("platform", "unknown-broker"); err != nil {
t.Fatalf("set platform flag: %v", err)
}
var stdout bytes.Buffer
err := runWithStdin(t, trading212SampleCSV, &stdout)
if err == nil {
t.Fatalf("expected an error for unsupported platform")
}
if !strings.Contains(err.Error(), "platform") {
t.Errorf("expected error to mention platform, got: %v", err)
}
}
-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)
},
}
}
+10 -10
View File
@@ -4,23 +4,23 @@ go 1.26.4
require ( require (
github.com/biter777/countries v1.7.5 github.com/biter777/countries v1.7.5
github.com/jedib0t/go-pretty/v6 v6.7.2 github.com/jedib0t/go-pretty/v6 v6.8.3
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.6.0
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.1.0
go.uber.org/mock v0.6.0 go.uber.org/mock v0.6.0
golang.org/x/time v0.14.0 golang.org/x/time v0.16.0
) )
require golang.org/x/sync v0.18.0 // indirect require golang.org/x/sync v0.24.0 // indirect
require ( require (
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.0 github.com/nicksnyder/go-i18n/v2 v2.7.0
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/mod v0.27.0 // indirect golang.org/x/mod v0.41.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.23.0 golang.org/x/text v0.43.0
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.51.0 // indirect
) )
tool go.uber.org/mock/mockgen tool go.uber.org/mock/mockgen
+24 -22
View File
@@ -1,41 +1,43 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q=
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E= github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jedib0t/go-pretty/v6 v6.7.2 h1:EYWgQNIH/+JsyHki7ns9OHyBKuHPkzrBo02uYjran7w= github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ=
github.com/jedib0t/go-pretty/v6 v6.7.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0=
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+19 -6
View File
@@ -96,11 +96,9 @@ func (fs *JSONStore) Load(ctx context.Context) (map[string]*FillerQueue, error)
// the same directory followed by an atomic rename so a crash mid-write cannot // the same directory followed by an atomic rename so a crash mid-write cannot
// leave a half-written state file. // leave a half-written state file.
func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error { func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) error {
state := State{ queues := make(map[string][]persistedFiller)
Version: StateVersion, latest := time.Time{}
Platform: fs.platform, earliest := time.Time{}
Queues: make(map[string][]persistedFiller),
}
for symbol, q := range queue { for symbol, q := range queue {
if q == nil || q.Len() == 0 { if q == nil || q.Len() == 0 {
@@ -119,8 +117,23 @@ func (fs *JSONStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
Price: f.Price(), Price: f.Price(),
Filled: f.Filled(), Filled: f.Filled(),
}) })
if f.Timestamp().After(latest) {
latest = f.Timestamp()
}
if f.Timestamp().Before(earliest) || earliest.IsZero() {
earliest = f.Timestamp()
}
} }
state.Queues[symbol] = persisted queues[symbol] = persisted
}
state := State{
Version: StateVersion,
Platform: fs.platform,
Queues: queues,
Latest: latest,
Earliest: earliest,
} }
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
+25 -5
View File
@@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/mocks" "github.com/nmoniz/any2anexoj/internal/mocks"
@@ -84,13 +85,25 @@ func TestJSONStore_RoundTrip(t *testing.T) {
// confirming the inline form is parseable guards against accidentally // confirming the inline form is parseable guards against accidentally
// double-encoding it again in the future). // double-encoding it again in the future).
var parsed struct { var parsed struct {
Queues map[string][]struct { Latest time.Time `json:"latest"`
Earliest time.Time `json:"earliest"`
Queues map[string][]struct {
RecordData json.RawMessage `json:"record_data"` RecordData json.RawMessage `json:"record_data"`
} `json:"queues"` } `json:"queues"`
} }
if err := json.Unmarshal(data, &parsed); err != nil { if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("saved state file is not valid JSON: %v", err) t.Fatalf("saved state file is not valid JSON: %v", err)
} }
// The latest/earliest bounds must be persisted alongside the queues.
// Every record in this test returns testRecordTime, so both bounds must
// equal it exactly.
if !parsed.Latest.Equal(testRecordTime) {
t.Errorf("state latest = %v; want %v", parsed.Latest, testRecordTime)
}
if !parsed.Earliest.Equal(testRecordTime) {
t.Errorf("state earliest = %v; want %v", parsed.Earliest, testRecordTime)
}
for symbol, lots := range parsed.Queues { for symbol, lots := range parsed.Queues {
for i, lot := range lots { for i, lot := range lots {
if len(lot.RecordData) == 0 { if len(lot.RecordData) == 0 {
@@ -341,13 +354,20 @@ func TestNewJSONStore_ValidatesArguments(t *testing.T) {
} }
} }
// newRecord builds a MockRecord whose Symbol() returns the given symbol. // testRecordTime is the deterministic timestamp returned by every record
// The JSONStore only reads Symbol() off the loaded Record during tests // built in this file. Using a fixed value keeps the latest/earliest bounds
// (quantity/price/filled come from the persisted struct fields), so all // written by JSONStore.Save predictable and assertable.
// other Record methods can be left as default-mocked values. var testRecordTime = time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
// newRecord builds a MockRecord whose Symbol() returns the given symbol and
// whose Timestamp() returns testRecordTime. The JSONStore only reads Symbol()
// and Timestamp() off the Record during tests (quantity/price/filled come
// from the persisted struct fields), so all other Record methods can be left
// as default-mocked values.
func newRecord(ctrl *gomock.Controller, symbol string) *mocks.MockRecord { func newRecord(ctrl *gomock.Controller, symbol string) *mocks.MockRecord {
r := mocks.NewMockRecord(ctrl) r := mocks.NewMockRecord(ctrl)
r.EXPECT().Symbol().Return(symbol).AnyTimes() r.EXPECT().Symbol().Return(symbol).AnyTimes()
r.EXPECT().Timestamp().Return(testRecordTime).AnyTimes()
return r return r
} }
+3
View File
@@ -3,6 +3,7 @@ package internal
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"time"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@@ -15,6 +16,8 @@ const StateVersion = "2"
type State struct { type State struct {
Version string `json:"version"` Version string `json:"version"`
Platform string `json:"platform"` Platform string `json:"platform"`
Latest time.Time `json:"latest"`
Earliest time.Time `json:"earliest"`
Queues map[string][]persistedFiller `json:"queues"` Queues map[string][]persistedFiller `json:"queues"`
} }
+50
View File
@@ -0,0 +1,50 @@
# Trading212
Turns Trading212 account statements (CSV) into the internal record model used
to build Anexo J reports. This file records how statement events are
interpreted, and why.
## Spin-off events
A spin-off distributes shares of one company to the shareholders of another.
Trading212 reports it as an `Action` of `Spin off`:
```
Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,
```
These rows are booked as **buys with a cost of zero**: quantity and timestamp
are kept, and every cost column (price, result, total, stamp duty reserve tax,
currency conversion fee, French transaction tax) is required to be zero —
anything non-zero, or a non-positive quantity, is logged and returned as an
error, stopping the run. A row that surprises the reader must be looked at by
a human, not silently booked.
### Why cost zero is fair
No money was paid for the new shares, and their value came out of the value
of the parent shares — on the distribution date the parent's price drops by
roughly the distributed value. The position's total basis is unchanged; it is
just partitioned over two lots: the spin-off lot at `0` and the parent lot at
its original, unreduced basis. Selling the spin-off lot books full proceeds as
gain; selling the parent lot shows the offsetting smaller gain or bigger loss.
Netted together, the result equals the true economic gain.
Example — buy 100 parent shares for 10,000; a spin-off delivers new shares
worth 2,000; later sell the new shares for 2,200 and the parent for 8,100:
| Lot | Basis (cost 0) | Sold for | Gain (cost 0) | Gain (pro-rata) |
| ------ | -------------: | -------: | ------------: | --------------: |
| New | 0 | 2,200 | 2,200 | 200 |
| Parent | 10,000 | 8,100 | -1,900 | 100 |
| Total | | | **300** | **300** |
Either partition of the same 10,000 basis yields the same net gain of 300
(true gain: 10,300 received − 10,000 paid) — as long as gains and losses are
assessed together, as they are in Anexo J within the same year. If the two
lots are sold in different years the per-year attribution differs (the total
does not). Trading212 statements don't report a pro-rata basis allocation, so
cost 0 also keeps the report reproducible from the statement alone.
> [!WARNING]
> Verify any outputs of this tool on your own or with a certified accountant.
+175 -69
View File
@@ -92,6 +92,10 @@ const (
SpinOff = "spin off" SpinOff = "spin off"
) )
// ReadRecord reads the next record from the statement, dispatching each
// action to the parser that validates exactly the columns that matter for
// it. Split open/close rows are paired up here because the pairing spans
// rows: the ratio is only known once the close row is seen.
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
var splitRec *splitRecord var splitRec *splitRecord
@@ -101,85 +105,53 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
return Record{}, fmt.Errorf("read record: %w", err) return Record{}, fmt.Errorf("read record: %w", err)
} }
if strings.ToLower(raw[0]) == "action" { action := strings.Trim(strings.ToLower(raw[0]), " ")
if action == "action" { // header row
continue continue
} }
symbol := raw[2] switch action {
qant, err := parseDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
price, err := parseDecimal(raw[7])
if err != nil {
return Record{}, fmt.Errorf("parse record price: %w", err)
}
ts, err := parseTimestamp(raw[1])
if err != nil {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
conversionFee, err := parseOptionalDecimal(raw[16])
if err != nil {
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
}
stampDutyTax, err := parseOptionalDecimal(raw[14])
if err != nil {
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
}
frenchTxTax, err := parseOptionalDecimal(raw[18])
if err != nil {
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
}
var kind internal.Kind
switch strings.ToLower(raw[0]) {
case MarketBuy, LimitBuy: case MarketBuy, LimitBuy:
kind = internal.KindBuy return rr.parseBuyRecord(ctx, raw)
case MarketSell, LimitSell: case MarketSell, LimitSell:
kind = internal.KindSell return rr.parseSellRecord(ctx, raw)
case SpinOff:
return rr.parseSpinOff(ctx, raw)
case StockSplitOpen: case StockSplitOpen:
if splitRec != nil { if splitRec != nil {
return nil, fmt.Errorf("split already open") return nil, fmt.Errorf("split already open")
} }
splitRec = &splitRecord{ rec, err := rr.parseTradeRecord(ctx, raw)
Record: Record{ if err != nil {
symbol: symbol, return nil, err
kind: internal.KindSplit,
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
},
} }
rec.kind = internal.KindSplit
splitRec = &splitRecord{Record: rec}
continue continue
case StockSplitClose: case StockSplitClose:
if splitRec == nil { if splitRec == nil {
return nil, fmt.Errorf("missing split open") return nil, fmt.Errorf("missing split open")
} }
splitRec.ratio = splitRec.Record.Quantity().Div(qant)
closeQty, err := parsePositiveDecimal(raw[6])
if err != nil {
return nil, fmt.Errorf("parse record quantity: %w", err)
}
splitRec.ratio = splitRec.Record.Quantity().Div(closeQty)
return splitRec, nil return splitRec, nil
case StokDistribution: case StokDistribution:
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
slog.Warn("Skipping 'stock distribution' event!", slog.Warn("Skipping 'stock distribution' event!",
slog.Any("ISIN", symbol), slog.Any("ISIN", raw[2]),
slog.Any("Quantity", qant),
)
continue
case SpinOff:
slog.Warn("Skipping 'spin off' event!",
slog.Any("ISIN", symbol),
slog.Any("Quantity", qant), slog.Any("Quantity", qant),
) )
continue continue
@@ -187,20 +159,140 @@ func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error)
default: default:
return Record{}, fmt.Errorf("parse record type: %s", raw[0]) return Record{}, fmt.Errorf("parse record type: %s", raw[0])
} }
return Record{
symbol: symbol,
kind: kind,
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
}, nil
} }
} }
// parseBuyRecord parses a market/limit buy row into a KindBuy Record.
func (rr RecordReader) parseBuyRecord(ctx context.Context, raw []string) (Record, error) {
rec, err := rr.parseTradeRecord(ctx, raw)
if err != nil {
return Record{}, err
}
rec.kind = internal.KindBuy
return rec, nil
}
// parseSellRecord parses a market/limit sell row into a KindSell Record.
func (rr RecordReader) parseSellRecord(ctx context.Context, raw []string) (Record, error) {
rec, err := rr.parseTradeRecord(ctx, raw)
if err != nil {
return Record{}, err
}
rec.kind = internal.KindSell
return rec, nil
}
// parseTradeRecord parses the columns shared by trade-shaped rows (quantity,
// price, timestamp, fees and taxes) into a Record with an unset Kind,
// leaving the kind decision to the caller. Split-open rows reuse it because
// their row shape is identical.
func (rr RecordReader) parseTradeRecord(ctx context.Context, raw []string) (Record, error) {
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
price, err := parseDecimal(raw[7])
if err != nil {
return Record{}, fmt.Errorf("parse record price: %w", err)
}
ts, err := parseTimestamp(raw[1])
if err != nil {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
conversionFee, err := parseOptionalDecimal(raw[16])
if err != nil {
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
}
stampDutyTax, err := parseOptionalDecimal(raw[14])
if err != nil {
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
}
frenchTxTax, err := parseOptionalDecimal(raw[18])
if err != nil {
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
}
return Record{
symbol: raw[2],
quantity: qant,
price: price,
fees: conversionFee,
taxes: stampDutyTax.Add(frenchTxTax),
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
}, nil
}
// parseSpinOff books a 'Spin off' statement row as a zero-cost buy. The
// zero-cost interpretation is only sound when no money moved with the
// distribution — see README.md in this package for the rationale — so every
// cost column is parsed as optional (an empty column means zero) and
// required to be zero: anything unexpected is logged and returned as an
// error so the run stops instead of silently booking a wrong lot.
func (rr RecordReader) parseSpinOff(ctx context.Context, raw []string) (Record, error) {
symbol := raw[2]
qant, err := parsePositiveDecimal(raw[6])
if err != nil {
return Record{}, fmt.Errorf("parse record quantity: %w", err)
}
ts, err := parseTimestamp(raw[1])
if err != nil {
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
}
// A slice (not a map) keeps validation and error reporting deterministic.
// result and total are only ever read here, for this row kind.
costs := []struct {
name string
column int
}{
{"price", 7},
{"result", 10},
{"total", 12},
{"stamp duty reserve tax", 14},
{"currency conversion fee", 16},
{"french transaction tax", 18},
}
for _, cost := range costs {
value, err := parseOptionalDecimal(raw[cost.column])
if err != nil {
return Record{}, fmt.Errorf("parse record %s: %w", cost.name, err)
}
if !value.IsZero() {
reason := fmt.Errorf("spin off with a non-zero %s (%s) for ISIN %s: refusing to book it as a zero-cost buy",
cost.name, value, symbol)
slog.Error("Unexpected 'spin off' row!",
slog.Any("err", reason),
slog.Any("ISIN", symbol),
)
return Record{}, reason
}
}
slog.Info("Booking 'spin off' event as a zero-cost buy!",
slog.Any("ISIN", symbol),
slog.Any("Quantity", qant),
)
return Record{
symbol: symbol,
kind: internal.KindBuy,
quantity: qant,
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: ts,
natureGetter: figiNatureGetter(ctx, rr.figi, symbol),
}, nil
}
func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature { func figiNatureGetter(ctx context.Context, of *ofigi.Client, isin string) func() internal.Nature {
return sync.OnceValue(func() internal.Nature { return sync.OnceValue(func() internal.Nature {
secType, err := of.SecurityTypeByISIN(ctx, isin) secType, err := of.SecurityTypeByISIN(ctx, isin)
@@ -238,6 +330,20 @@ func parseOptionalDecimal(s string) (decimal.Decimal, error) {
return parseDecimal(s) return parseDecimal(s)
} }
// parsePositiveDecimal parses s as a decimal and requires it to be strictly
// positive: empty, malformed, zero and negative values are rejected. A lot
// (or a split-ratio denominator) is meaningless without a positive quantity.
func parsePositiveDecimal(s string) (decimal.Decimal, error) {
value, err := parseDecimal(s)
if err != nil {
return decimal.Decimal{}, err
}
if !value.IsPositive() {
return decimal.Decimal{}, fmt.Errorf("non-positive value %q", s)
}
return value, nil
}
// parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped // parseTimestamp parses a Trading212 timestamp string. Trading212 has shipped
// rows both without a timezone (treated here as UTC) and with an explicit // rows both without a timezone (treated here as UTC) and with an explicit
// numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker. // numeric offset (e.g. "+00:00", "-05:00", "+01:00") or a "Z" UTC marker.
+63 -4
View File
@@ -138,6 +138,69 @@ func TestRecordReader_ReadRecord(t *testing.T) {
r: bytes.NewBufferString(`Market buy,not-a-timestamp,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`), r: bytes.NewBufferString(`Market buy,not-a-timestamp,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
wantErr: true, wantErr: true,
}, },
{
name: "spin off booked as zero-cost buy",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`),
want: Record{
symbol: "XX1234567890",
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "0.0406450000"),
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: time.Date(2026, 2, 11, 13, 41, 43, 0, time.FixedZone("UTC", 0)),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
{
name: "spin off with a non-zero price is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,45.2300000000,USD,,,,0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero result is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,1.17995999,1.25,"EUR",0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero total is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,1.25,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero stamp duty reserve tax is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",0.25,"EUR",,,,,`),
wantErr: true,
},
{
name: "spin off with a non-zero currency conversion fee is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,0.02,"EUR",,`),
wantErr: true,
},
{
name: "spin off with a non-zero french transaction tax is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,0.1,"EUR"`),
wantErr: true,
},
{
name: "spin off with a non-positive quantity is rejected",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0E-10,0E-10,USD,,,,0.00,"EUR",,,,,,`),
wantErr: true,
},
{
name: "spin off with an empty price column is booked as a zero-cost buy",
r: bytes.NewBufferString(`Spin off,2026-02-11 13:41:43+00:00,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,0.0406450000,,USD,,,,0.00,"EUR",,,,,,`),
want: Record{
symbol: "XX1234567890",
kind: internal.KindBuy,
quantity: ShouldParseDecimal(t, "0.0406450000"),
price: decimal.Zero,
fees: decimal.Zero,
taxes: decimal.Zero,
timestamp: time.Date(2026, 2, 11, 13, 41, 43, 0, time.FixedZone("UTC", 0)),
natureGetter: func() internal.Nature { return internal.NatureG01 },
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -377,10 +440,6 @@ func TestRecordReader_ReadRecord_SkipsCorporateActions(t *testing.T) {
name string name string
row string row string
}{ }{
{
name: "spin off",
row: `Spin off,2026-02-11 13:41:43+00:00,US9418481035,WAT,"Waters",EOF46451921527,0.0406450000,0E-10,USD,,,,0.00,"EUR",,,,,,`,
},
{ {
name: "stock distribution", name: "stock distribution",
row: `Stock distribution,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`, row: `Stock distribution,2025-08-04 11:45:30,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`,
@@ -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",
+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)
}
}
+6
View File
@@ -0,0 +1,6 @@
@charset "UTF-8";/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
/*# sourceMappingURL=bootstrap.min.css.map */
+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>
<style>{{.CSS}}</style>
</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)
}
}
+85
View File
@@ -0,0 +1,85 @@
// 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. The official Bootstrap
// 5.3.3 stylesheet is inlined via go:embed, so the document renders fully
// offline.
package html
import (
_ "embed"
"fmt"
"html/template"
"io"
"time"
"github.com/nmoniz/any2anexoj/internal"
"github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef"
)
// bootstrapMinCSS is the official Bootstrap 5.3.3 minified CSS distribution
// file, embedded so every rendered document renders fully offline. After any
// version bump, re-verify provenance with:
//
// openssl dgst -sha384 -binary bootstrap.min.css | base64
//
// It must print the official SRI hash published at
// https://getbootstrap.com/docs/5.3/getting-started/download/
// (sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH).
//
//go:embed bootstrap.min.css
var bootstrapMinCSS string
// Writer writes a complete HTML document for an AggregatorWriter.
type Writer struct {
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
CSS template.CSS
TableFragment template.HTML
GeneratedAt string
}{
Lang: hw.lang,
Title: title,
CSS: template.CSS(bootstrapMinCSS),
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
}
+188
View File
@@ -0,0 +1,188 @@
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>`,
`<style>`,
`Bootstrap v5.3.3`,
`<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)
}
// The document must be fully self-contained: no external stylesheet
// <link> and no CDN reference anywhere.
if strings.Contains(got, "<link") {
t.Errorf("expected no external stylesheet <link>; document must be self-contained\nGot:\n%s", got)
}
if strings.Contains(got, "cdn.jsdelivr.net") {
t.Errorf("expected no CDN references; document must be self-contained\nGot:\n%s", got)
}
// Generated timestamp must be valid RFC3339 UTC.
startIdx := strings.Index(got, "Generated at ")
if startIdx == -1 {
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,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)
} }
+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 ( 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"
} }
} }
+20
View File
@@ -0,0 +1,20 @@
{
"extends": [
"config:base",
":disableDependencyDashboard"
],
"packageRules": [
{
"updateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"automerge": false
}
],
"postUpdateOptions": [
"gomodTidy"
]
}