From b12c519fdbffbe328df478433a8a22a0154e94af Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Thu, 4 Dec 2025 13:04:30 +0000 Subject: [PATCH 1/5] separate print logic from write logic --- cmd/any2anexoj-cli/main.go | 6 +- cmd/any2anexoj-cli/pretty_printer.go | 102 ++++++++ cmd/any2anexoj-cli/pretty_printer_test.go | 1 + internal/aggregator_writer.go | 78 ++++++ internal/aggregator_writer_test.go | 277 ++++++++++++++++++++++ internal/table_writer.go | 115 --------- internal/table_writer_test.go | 116 --------- 7 files changed, 462 insertions(+), 233 deletions(-) create mode 100644 cmd/any2anexoj-cli/pretty_printer.go create mode 100644 cmd/any2anexoj-cli/pretty_printer_test.go create mode 100644 internal/aggregator_writer.go create mode 100644 internal/aggregator_writer_test.go delete mode 100644 internal/table_writer.go delete mode 100644 internal/table_writer_test.go diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 2b02d95..03e2c6a 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -55,7 +55,7 @@ func run(ctx context.Context, platform string) error { reader := factory() - writer := internal.NewTableWriter(os.Stdout) + writer := internal.NewAggregatorWriter() eg.Go(func() error { return internal.BuildReport(ctx, reader, writer) @@ -66,7 +66,9 @@ func run(ctx context.Context, platform string) error { return err } - writer.Render() + printer := NewPrettyPrinter(os.Stdout) + + printer.Render(writer) return nil } diff --git a/cmd/any2anexoj-cli/pretty_printer.go b/cmd/any2anexoj-cli/pretty_printer.go new file mode 100644 index 0000000..4ad4f2d --- /dev/null +++ b/cmd/any2anexoj-cli/pretty_printer.go @@ -0,0 +1,102 @@ +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 +} + +func NewPrettyPrinter(w io.Writer) *PrettyPrinter { + t := table.NewWriter() + t.SetOutputMirror(w) + t.SetAutoIndex(true) + t.SetStyle(table.StyleLight) + t.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: t, + output: w, + } +} + +func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { + pp.table.AppendHeader(table.Row{"", "", "Realisation", "Realisation", "Realisation", "Realisation", "Acquisition", "Acquisition", "Acquisition", "Acquisition", "", "", ""}, table.RowConfig{AutoMerge: true}) + pp.table.AppendHeader(table.Row{"Source Country", "Code", "Year", "Month", "Day", "Value", "Year", "Month", "Day", "Value", "Expenses", "Paid Taxes", "Counter Country"}) + + 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, + 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, + } +} + +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) + }, + } +} diff --git a/cmd/any2anexoj-cli/pretty_printer_test.go b/cmd/any2anexoj-cli/pretty_printer_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/cmd/any2anexoj-cli/pretty_printer_test.go @@ -0,0 +1 @@ +package main diff --git a/internal/aggregator_writer.go b/internal/aggregator_writer.go new file mode 100644 index 0000000..c7876fe --- /dev/null +++ b/internal/aggregator_writer.go @@ -0,0 +1,78 @@ +package internal + +import ( + "context" + "iter" + "sync" + + "github.com/shopspring/decimal" +) + +// AggregatorWriter tracks ReportItem totals. +type AggregatorWriter struct { + mu sync.RWMutex + + items []ReportItem + + totalEarned decimal.Decimal + totalSpent decimal.Decimal + totalFees decimal.Decimal + totalTaxes decimal.Decimal +} + +func NewAggregatorWriter() *AggregatorWriter { + return &AggregatorWriter{} +} + +func (aw *AggregatorWriter) Write(_ context.Context, ri ReportItem) error { + aw.mu.Lock() + defer aw.mu.Unlock() + + aw.items = append(aw.items, ri) + + aw.totalEarned = aw.totalEarned.Add(ri.SellValue.Round(2)) + aw.totalSpent = aw.totalSpent.Add(ri.BuyValue.Round(2)) + aw.totalFees = aw.totalFees.Add(ri.Fees.Round(2)) + aw.totalTaxes = aw.totalTaxes.Add(ri.Taxes.Round(2)) + + return nil +} + +func (aw *AggregatorWriter) Iter() iter.Seq[ReportItem] { + aw.mu.RLock() + itemsCopy := make([]ReportItem, len(aw.items)) + copy(itemsCopy, aw.items) + aw.mu.RUnlock() + + return func(yield func(ReportItem) bool) { + for _, ri := range itemsCopy { + if !yield(ri) { + return + } + } + } +} + +func (aw *AggregatorWriter) TotalEarned() decimal.Decimal { + aw.mu.RLock() + defer aw.mu.RUnlock() + return aw.totalEarned +} + +func (aw *AggregatorWriter) TotalSpent() decimal.Decimal { + aw.mu.RLock() + defer aw.mu.RUnlock() + return aw.totalSpent +} + +func (aw *AggregatorWriter) TotalFees() decimal.Decimal { + aw.mu.RLock() + defer aw.mu.RUnlock() + return aw.totalFees +} + +func (aw *AggregatorWriter) TotalTaxes() decimal.Decimal { + aw.mu.RLock() + defer aw.mu.RUnlock() + return aw.totalTaxes +} diff --git a/internal/aggregator_writer_test.go b/internal/aggregator_writer_test.go new file mode 100644 index 0000000..5c5f383 --- /dev/null +++ b/internal/aggregator_writer_test.go @@ -0,0 +1,277 @@ +package internal_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/nmoniz/any2anexoj/internal" + "github.com/shopspring/decimal" +) + +func TestAggregatorWriter_Write(t *testing.T) { + tests := []struct { + name string + items []internal.ReportItem + wantEarned decimal.Decimal + wantSpent decimal.Decimal + wantFees decimal.Decimal + wantTaxes decimal.Decimal + }{ + { + name: "single write updates all totals", + items: []internal.ReportItem{ + { + Symbol: "AAPL", + BuyValue: decimal.NewFromFloat(100.50), + SellValue: decimal.NewFromFloat(150.75), + Fees: decimal.NewFromFloat(2.50), + Taxes: decimal.NewFromFloat(5.25), + BuyTimestamp: time.Now(), + SellTimestamp: time.Now(), + }, + }, + wantEarned: decimal.NewFromFloat(150.75), + wantSpent: decimal.NewFromFloat(100.50), + wantFees: decimal.NewFromFloat(2.50), + wantTaxes: decimal.NewFromFloat(5.25), + }, + { + name: "multiple writes accumulate totals", + items: []internal.ReportItem{ + { + BuyValue: decimal.NewFromFloat(100.00), + SellValue: decimal.NewFromFloat(120.00), + Fees: decimal.NewFromFloat(1.00), + Taxes: decimal.NewFromFloat(2.00), + }, + { + BuyValue: decimal.NewFromFloat(200.00), + SellValue: decimal.NewFromFloat(250.00), + Fees: decimal.NewFromFloat(3.00), + Taxes: decimal.NewFromFloat(4.00), + }, + { + BuyValue: decimal.NewFromFloat(50.00), + SellValue: decimal.NewFromFloat(55.00), + Fees: decimal.NewFromFloat(0.50), + Taxes: decimal.NewFromFloat(1.50), + }, + }, + wantEarned: decimal.NewFromFloat(425.00), + wantSpent: decimal.NewFromFloat(350.00), + wantFees: decimal.NewFromFloat(4.50), + wantTaxes: decimal.NewFromFloat(7.50), + }, + { + name: "empty writer returns zero totals", + items: []internal.ReportItem{}, + wantEarned: decimal.Zero, + wantSpent: decimal.Zero, + wantFees: decimal.Zero, + wantTaxes: decimal.Zero, + }, + { + name: "handles zero values", + items: []internal.ReportItem{ + { + BuyValue: decimal.Zero, + SellValue: decimal.Zero, + Fees: decimal.Zero, + Taxes: decimal.Zero, + }, + }, + wantEarned: decimal.Zero, + wantSpent: decimal.Zero, + wantFees: decimal.Zero, + wantTaxes: decimal.Zero, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + aw := &internal.AggregatorWriter{} + ctx := context.Background() + + for _, item := range tt.items { + if err := aw.Write(ctx, item); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + assertDecimalEqual(t, "TotalEarned", tt.wantEarned, aw.TotalEarned()) + assertDecimalEqual(t, "TotalSpent", tt.wantSpent, aw.TotalSpent()) + assertDecimalEqual(t, "TotalFees", tt.wantFees, aw.TotalFees()) + assertDecimalEqual(t, "TotalTaxes", tt.wantTaxes, aw.TotalTaxes()) + }) + } +} + +func TestAggregatorWriter_Rounding(t *testing.T) { + tests := []struct { + name string + items []internal.ReportItem + wantEarned decimal.Decimal + wantSpent decimal.Decimal + wantFees decimal.Decimal + wantTaxes decimal.Decimal + }{ + { + name: "rounds to 2 decimal places", + items: []internal.ReportItem{ + { + BuyValue: decimal.NewFromFloat(100.123456), + SellValue: decimal.NewFromFloat(150.987654), + Fees: decimal.NewFromFloat(2.555555), + Taxes: decimal.NewFromFloat(5.444444), + }, + }, + wantEarned: decimal.NewFromFloat(150.99), + wantSpent: decimal.NewFromFloat(100.12), + wantFees: decimal.NewFromFloat(2.56), + wantTaxes: decimal.NewFromFloat(5.44), + }, + { + name: "rounding accumulates correctly across multiple writes", + items: []internal.ReportItem{ + { + BuyValue: decimal.NewFromFloat(10.111), + SellValue: decimal.NewFromFloat(15.999), + Fees: decimal.NewFromFloat(0.555), + Taxes: decimal.NewFromFloat(1.445), + }, + { + BuyValue: decimal.NewFromFloat(20.222), + SellValue: decimal.NewFromFloat(25.001), + Fees: decimal.NewFromFloat(0.444), + Taxes: decimal.NewFromFloat(0.555), + }, + }, + // Each write rounds individually, then accumulates + // First: 10.11 + 20.22 = 30.33 + // Second: 16.00 + 25.00 = 41.00 + // Fees: 0.56 + 0.44 = 1.00 + // Taxes: 1.45 + 0.56 = 2.01 + wantSpent: decimal.NewFromFloat(30.33), + wantEarned: decimal.NewFromFloat(41.00), + wantFees: decimal.NewFromFloat(1.00), + wantTaxes: decimal.NewFromFloat(2.01), + }, + { + name: "handles small fractions", + items: []internal.ReportItem{ + { + BuyValue: decimal.NewFromFloat(0.001), + SellValue: decimal.NewFromFloat(0.009), + Fees: decimal.NewFromFloat(0.0055), + Taxes: decimal.NewFromFloat(0.0045), + }, + }, + wantSpent: decimal.NewFromFloat(0.00), + wantEarned: decimal.NewFromFloat(0.01), + wantFees: decimal.NewFromFloat(0.01), + wantTaxes: decimal.NewFromFloat(0.00), + }, + { + name: "handles large numbers with precision", + items: []internal.ReportItem{ + { + BuyValue: decimal.NewFromFloat(999999.996), + SellValue: decimal.NewFromFloat(1000000.004), + Fees: decimal.NewFromFloat(12345.678), + Taxes: decimal.NewFromFloat(54321.123), + }, + }, + wantSpent: decimal.NewFromFloat(1000000.00), + wantEarned: decimal.NewFromFloat(1000000.00), + wantFees: decimal.NewFromFloat(12345.68), + wantTaxes: decimal.NewFromFloat(54321.12), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + aw := &internal.AggregatorWriter{} + ctx := context.Background() + + for _, item := range tt.items { + if err := aw.Write(ctx, item); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + assertDecimalEqual(t, "TotalEarned", tt.wantEarned, aw.TotalEarned()) + assertDecimalEqual(t, "TotalSpent", tt.wantSpent, aw.TotalSpent()) + assertDecimalEqual(t, "TotalFees", tt.wantFees, aw.TotalFees()) + assertDecimalEqual(t, "TotalTaxes", tt.wantTaxes, aw.TotalTaxes()) + }) + } +} + +func TestAggregatorWriter_Items(t *testing.T) { + aw := &internal.AggregatorWriter{} + ctx := context.Background() + + for range 10 { + item := internal.ReportItem{Symbol: "TEST"} + if err := aw.Write(ctx, item); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + count := 0 + for range aw.Iter() { + count++ + } + + if count != 5 { + t.Errorf("expected for loop to stop at 5 items, got %d", count) + } +} + +func TestAggregatorWriter_ThreadSafety(t *testing.T) { + aw := &internal.AggregatorWriter{} + ctx := context.Background() + + numGoroutines := 100 + writesPerGoroutine := 100 + + var wg sync.WaitGroup + for range numGoroutines { + wg.Go(func() { + for range writesPerGoroutine { + item := internal.ReportItem{ + BuyValue: decimal.NewFromFloat(1.00), + SellValue: decimal.NewFromFloat(2.00), + Fees: decimal.NewFromFloat(0.10), + Taxes: decimal.NewFromFloat(0.20), + } + if err := aw.Write(ctx, item); err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } + + wg.Wait() + + // Verify totals are correct + wantWrites := numGoroutines * writesPerGoroutine + wantSpent := decimal.NewFromFloat(float64(wantWrites) * 1.00) + wantEarned := decimal.NewFromFloat(float64(wantWrites) * 2.00) + wantFees := decimal.NewFromFloat(float64(wantWrites) * 0.10) + wantTaxes := decimal.NewFromFloat(float64(wantWrites) * 0.20) + + assertDecimalEqual(t, "TotalSpent", wantSpent, aw.TotalSpent()) + assertDecimalEqual(t, "TotalEarned", wantEarned, aw.TotalEarned()) + assertDecimalEqual(t, "TotalFees", wantFees, aw.TotalFees()) + assertDecimalEqual(t, "TotalTaxes", wantTaxes, aw.TotalTaxes()) +} + +// Helper function to assert decimal equality +func assertDecimalEqual(t *testing.T, name string, expected, actual decimal.Decimal) { + t.Helper() + + if !expected.Equal(actual) { + t.Errorf("want %s to be %s but got %s", name, expected.String(), actual.String()) + } +} diff --git a/internal/table_writer.go b/internal/table_writer.go deleted file mode 100644 index 07834c0..0000000 --- a/internal/table_writer.go +++ /dev/null @@ -1,115 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "io" - - "github.com/biter777/countries" - "github.com/jedib0t/go-pretty/v6/table" - "github.com/jedib0t/go-pretty/v6/text" - "github.com/shopspring/decimal" -) - -// TableWriter writes a simple, human readable, table row to the provided io.Writer for each -// ReportItem received. -type TableWriter struct { - table table.Writer - output io.Writer - - totalEarned decimal.Decimal - totalSpent decimal.Decimal - totalFees decimal.Decimal - totalTaxes decimal.Decimal -} - -func NewTableWriter(w io.Writer) *TableWriter { - t := table.NewWriter() - t.SetOutputMirror(w) - t.SetAutoIndex(true) - t.SetStyle(table.StyleLight) - t.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), - }) - - t.AppendHeader(table.Row{"", "", "Realisation", "Realisation", "Realisation", "Realisation", "Acquisition", "Acquisition", "Acquisition", "Acquisition", "", "", ""}, table.RowConfig{AutoMerge: true}) - t.AppendHeader(table.Row{"Source Country", "Code", "Year", "Month", "Day", "Value", "Year", "Month", "Day", "Value", "Expenses", "Paid Taxes", "Counter Country"}) - - return &TableWriter{ - table: t, - output: w, - } -} - -func (tw *TableWriter) Write(_ context.Context, ri ReportItem) error { - tw.totalEarned = tw.totalEarned.Add(ri.SellValue.Round(2)) - tw.totalSpent = tw.totalSpent.Add(ri.BuyValue.Round(2)) - tw.totalFees = tw.totalFees.Add(ri.Fees.Round(2)) - tw.totalTaxes = tw.totalTaxes.Add(ri.Taxes.Round(2)) - - tw.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, - }) - - return nil -} - -func (tw *TableWriter) Render() { - tw.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", tw.totalEarned.StringFixed(2), "", "", "", tw.totalSpent.StringFixed(2), tw.totalFees.StringFixed(2), tw.totalTaxes.StringFixed(2)}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) - tw.table.Render() -} - -func colEuros(n int) table.ColumnConfig { - return table.ColumnConfig{ - Number: n, - Align: text.AlignRight, - AlignFooter: text.AlignRight, - AlignHeader: text.AlignRight, - WidthMin: 12, - 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, - } -} - -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) - }, - } -} diff --git a/internal/table_writer_test.go b/internal/table_writer_test.go deleted file mode 100644 index 2d642b3..0000000 --- a/internal/table_writer_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package internal - -import ( - "bytes" - "testing" - "time" - - "github.com/shopspring/decimal" -) - -func TestTableWriter_Write(t *testing.T) { - tNow := time.Now() - - tests := []struct { - name string - items []ReportItem - wantTotalSpent decimal.Decimal - wantTotalEarned decimal.Decimal - wantTotalTaxes decimal.Decimal - wantTotalFees decimal.Decimal - }{ - { - name: "empty", - }, - { - name: "single item positive", - items: []ReportItem{ - { - BuyValue: decimal.NewFromFloat(100.0), - SellValue: decimal.NewFromFloat(200.0), - SellTimestamp: tNow, - Taxes: decimal.NewFromFloat(2.5), - Fees: decimal.NewFromFloat(2.5), - }, - }, - wantTotalSpent: decimal.NewFromFloat(100.0), - wantTotalEarned: decimal.NewFromFloat(200.0), - wantTotalTaxes: decimal.NewFromFloat(2.5), - wantTotalFees: decimal.NewFromFloat(2.5), - }, - { - name: "single item negative", - items: []ReportItem{ - { - BuyValue: decimal.NewFromFloat(200.0), - SellValue: decimal.NewFromFloat(150.0), - SellTimestamp: tNow, - Taxes: decimal.NewFromFloat(2.5), - Fees: decimal.NewFromFloat(2.5), - }, - }, - wantTotalSpent: decimal.NewFromFloat(200.0), - wantTotalEarned: decimal.NewFromFloat(150.0), - wantTotalTaxes: decimal.NewFromFloat(2.5), - wantTotalFees: decimal.NewFromFloat(2.5), - }, - { - name: "multiple items", - items: []ReportItem{ - { - Symbol: "US1912161007", - BuyValue: decimal.NewFromFloat(100.0), - SellValue: decimal.NewFromFloat(200.0), - SellTimestamp: tNow, - Taxes: decimal.NewFromFloat(2.5), - Fees: decimal.NewFromFloat(2.5), - }, - { - Symbol: "US1912161007", - BuyValue: decimal.NewFromFloat(200.0), - SellValue: decimal.NewFromFloat(150.0), - SellTimestamp: tNow.Add(1), - Taxes: decimal.NewFromFloat(2.5), - Fees: decimal.NewFromFloat(2.5), - }, - }, - wantTotalSpent: decimal.NewFromFloat(300.0), - wantTotalEarned: decimal.NewFromFloat(350.0), - wantTotalTaxes: decimal.NewFromFloat(5.0), - wantTotalFees: decimal.NewFromFloat(5.0), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf := new(bytes.Buffer) - tw := NewTableWriter(buf) - - for _, item := range tt.items { - err := tw.Write(t.Context(), item) - if err != nil { - t.Fatalf("unexpected error on write: %v", err) - } - } - - if tw.table.Length() != len(tt.items) { - t.Fatalf("want %d items in table but got %d", len(tt.items), tw.table.Length()) - } - - if !tw.totalSpent.Equal(tt.wantTotalSpent) { - t.Errorf("want totalSpent to be %v but got %v", tt.wantTotalSpent, tw.totalSpent) - } - - if !tw.totalEarned.Equal(tt.wantTotalEarned) { - t.Errorf("want totalEarned to be %v but got %v", tt.wantTotalEarned, tw.totalEarned) - } - - if !tw.totalTaxes.Equal(tt.wantTotalTaxes) { - t.Errorf("want totalTaxes to be %v but got %v", tt.wantTotalTaxes, tw.totalTaxes) - } - - if !tw.totalFees.Equal(tt.wantTotalFees) { - t.Errorf("want totalFees to be %v but got %v", tt.wantTotalFees, tw.totalFees) - } - }) - } -} From 21147954cb4f8cf03cf9538710eac95b4da704d6 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Thu, 4 Dec 2025 16:03:10 +0000 Subject: [PATCH 2/5] support translations --- cmd/any2anexoj-cli/localizer.go | 46 +++++++++++++++++++++++++ cmd/any2anexoj-cli/main.go | 19 ++++++++-- cmd/any2anexoj-cli/pretty_printer.go | 44 ++++++++++++++++------- cmd/any2anexoj-cli/translations/en.json | 46 +++++++++++++++++++++++++ cmd/any2anexoj-cli/translations/pt.json | 46 +++++++++++++++++++++++++ go.mod | 3 +- go.sum | 4 +++ 7 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 cmd/any2anexoj-cli/localizer.go create mode 100644 cmd/any2anexoj-cli/translations/en.json create mode 100644 cmd/any2anexoj-cli/translations/pt.json diff --git a/cmd/any2anexoj-cli/localizer.go b/cmd/any2anexoj-cli/localizer.go new file mode 100644 index 0000000..18691a2 --- /dev/null +++ b/cmd/any2anexoj-cli/localizer.go @@ -0,0 +1,46 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +//go:embed translations/*.json +var translationsFS embed.FS + +type Localizer struct { + *i18n.Localizer +} + +func NewLocalizer(lang string) (*Localizer, error) { + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + + _, err := bundle.LoadMessageFileFS(translationsFS, "translations/en.json") + if err != nil { + return nil, fmt.Errorf("loading english messages: %w", err) + } + + _, err = bundle.LoadMessageFileFS(translationsFS, "translations/pt.json") + if err != nil { + return nil, fmt.Errorf("loading portuguese messages: %w", err) + } + + localizer := i18n.NewLocalizer(bundle, lang) + + return &Localizer{ + Localizer: localizer, + }, nil +} + +func (t Localizer) Translate(key string, count int, values map[string]any) string { + return t.MustLocalize(&i18n.LocalizeConfig{ + MessageID: key, + TemplateData: values, + PluralCount: count, + }) +} diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 03e2c6a..d41dcd5 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -13,12 +13,15 @@ import ( "github.com/nmoniz/any2anexoj/internal/trading212" "github.com/spf13/pflag" "golang.org/x/sync/errgroup" + "golang.org/x/text/language" ) // TODO: once we support more brokers or exchanges we should make this parameter required and // remove/change default var platform = pflag.StringP("platform", "p", "trading212", "one of the supported platforms") +var lang = pflag.StringP("language", "l", language.Portuguese.String(), "2 letter language code") + var readerFactories = map[string]func() internal.RecordReader{ "trading212": func() internal.RecordReader { return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second})) @@ -33,14 +36,19 @@ func main() { os.Exit(1) } - err := run(context.Background(), *platform) + if lang == nil || len(*lang) == 0 { + slog.Error("--language flag is required") + os.Exit(1) + } + + err := run(context.Background(), *platform, *lang) if err != nil { slog.Error("found a fatal issue", slog.Any("err", err)) os.Exit(1) } } -func run(ctx context.Context, platform string) error { +func run(ctx context.Context, platform, lang string) error { ctx, cancel := signal.NotifyContext(ctx, os.Kill, os.Interrupt) defer cancel() @@ -66,7 +74,12 @@ func run(ctx context.Context, platform string) error { return err } - printer := NewPrettyPrinter(os.Stdout) + loc, err := NewLocalizer(lang) + if err != nil { + return fmt.Errorf("create localizer: %w", err) + } + + printer := NewPrettyPrinter(os.Stdout, loc) printer.Render(writer) diff --git a/cmd/any2anexoj-cli/pretty_printer.go b/cmd/any2anexoj-cli/pretty_printer.go index 4ad4f2d..f102573 100644 --- a/cmd/any2anexoj-cli/pretty_printer.go +++ b/cmd/any2anexoj-cli/pretty_printer.go @@ -13,16 +13,21 @@ import ( // 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 + table table.Writer + output io.Writer + translator Translator } -func NewPrettyPrinter(w io.Writer) *PrettyPrinter { - t := table.NewWriter() - t.SetOutputMirror(w) - t.SetAutoIndex(true) - t.SetStyle(table.StyleLight) - t.SetColumnConfigs([]table.ColumnConfig{ +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), @@ -39,14 +44,27 @@ func NewPrettyPrinter(w io.Writer) *PrettyPrinter { }) return &PrettyPrinter{ - table: t, - output: w, + table: tw, + output: w, + translator: tr, } } func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { - pp.table.AppendHeader(table.Row{"", "", "Realisation", "Realisation", "Realisation", "Realisation", "Acquisition", "Acquisition", "Acquisition", "Acquisition", "", "", ""}, table.RowConfig{AutoMerge: true}) - pp.table.AppendHeader(table.Row{"Source Country", "Code", "Year", "Month", "Day", "Value", "Year", "Month", "Day", "Value", "Expenses", "Paid Taxes", "Counter Country"}) + 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{ @@ -68,6 +86,7 @@ func colEuros(n int) table.ColumnConfig { AlignFooter: text.AlignRight, AlignHeader: text.AlignRight, WidthMin: 12, + WidthMax: 15, Transformer: func(val any) string { return fmt.Sprintf("%v €", val) }, @@ -83,6 +102,7 @@ func colOther(n int) table.ColumnConfig { Align: text.AlignLeft, AlignFooter: text.AlignLeft, AlignHeader: text.AlignLeft, + WidthMax: 12, } } diff --git a/cmd/any2anexoj-cli/translations/en.json b/cmd/any2anexoj-cli/translations/en.json new file mode 100644 index 0000000..cf17dbc --- /dev/null +++ b/cmd/any2anexoj-cli/translations/en.json @@ -0,0 +1,46 @@ +{ + "realization": { + "one": "Realization", + "other": "Realizations" + }, + "acquisition": { + "one": "Acquisition", + "other": "Acquisitions" + }, + "source_country": { + "one": "Source country", + "other": "Source countries" + }, + "counter_country": { + "one": "Counter country", + "other": "Counter countries" + }, + "year": { + "one": "Year", + "other": "Years" + }, + "month": { + "one": "Month", + "other": "Months" + }, + "day": { + "one": "Day", + "other": "Days" + }, + "value": { + "one": "Value", + "other": "Values" + }, + "code": { + "one": "Code", + "other": "Codes" + }, + "expenses": { + "one": "Expense and charge", + "other": "Expenses and charges" + }, + "foreign_tax_paid": { + "one": "Tax paid abroad", + "other": "Taxes paid abroad" + } +} diff --git a/cmd/any2anexoj-cli/translations/pt.json b/cmd/any2anexoj-cli/translations/pt.json new file mode 100644 index 0000000..60b6339 --- /dev/null +++ b/cmd/any2anexoj-cli/translations/pt.json @@ -0,0 +1,46 @@ +{ + "realization": { + "one": "Realização", + "other": "Realizações" + }, + "acquisition": { + "one": "Aquisição", + "other": "Aquisições" + }, + "source_country": { + "one": "País da fonte", + "other": "Países da fonte" + }, + "counter_country": { + "one": "País da contraparte", + "other": "Países da contraparte" + }, + "year": { + "one": "Ano", + "other": "Anos" + }, + "month": { + "one": "Mês", + "other": "Meses" + }, + "day": { + "one": "Dia", + "other": "Dias" + }, + "value": { + "one": "Valor", + "other": "Valores" + }, + "code": { + "one": "Código", + "other": "Códigos" + }, + "expenses": { + "one": "Despesa e encargo", + "other": "Despesas e encargos" + }, + "foreign_tax_paid": { + "one": "Imposto pago no estrangeiro", + "other": "Impostos pagos no estrangeiro" + } +} diff --git a/go.mod b/go.mod index 9a68879..6b06e01 100644 --- a/go.mod +++ b/go.mod @@ -14,10 +14,11 @@ require ( require ( github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.0 github.com/rivo/uniseg v0.4.7 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 199b11d..9de2131 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/jedib0t/go-pretty/v6 v6.7.2 h1:EYWgQNIH/+JsyHki7ns9OHyBKuHPkzrBo02uYj github.com/jedib0t/go-pretty/v6 v6.7.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= 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/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= +github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -29,6 +31,8 @@ golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= From 38db50b879db9981377310b1a44452b487f68967 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Thu, 4 Dec 2025 16:23:26 +0000 Subject: [PATCH 3/5] add and fix tests --- cmd/any2anexoj-cli/localizer_test.go | 24 ++++++++++++++++++++++++ internal/aggregator_writer_test.go | 13 ++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 cmd/any2anexoj-cli/localizer_test.go diff --git a/cmd/any2anexoj-cli/localizer_test.go b/cmd/any2anexoj-cli/localizer_test.go new file mode 100644 index 0000000..2bad62f --- /dev/null +++ b/cmd/any2anexoj-cli/localizer_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +func TestNewLocalizer(t *testing.T) { + tests := []struct { + name string + lang string + }{ + {"english", "en"}, + {"portuguese", "pt"}, + {"english with region", "en-US"}, + {"portuguese with region", "pt-BR"}, + {"unknown language falls back to default", "!!"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewLocalizer(tt.lang) + if err != nil { + t.Fatalf("want success call but failed: %v", err) + } + }) + } +} diff --git a/internal/aggregator_writer_test.go b/internal/aggregator_writer_test.go index 5c5f383..aab8dc4 100644 --- a/internal/aggregator_writer_test.go +++ b/internal/aggregator_writer_test.go @@ -211,7 +211,7 @@ func TestAggregatorWriter_Items(t *testing.T) { aw := &internal.AggregatorWriter{} ctx := context.Background() - for range 10 { + for range 5 { item := internal.ReportItem{Symbol: "TEST"} if err := aw.Write(ctx, item); err != nil { t.Fatalf("unexpected error: %v", err) @@ -226,6 +226,17 @@ func TestAggregatorWriter_Items(t *testing.T) { if count != 5 { t.Errorf("expected for loop to stop at 5 items, got %d", count) } + + count = 0 + for range aw.Iter() { + count++ + if count == 3 { + break + } + } + if count != 3 { + t.Errorf("expected for loop to stop at 3 items, got %d", count) + } } func TestAggregatorWriter_ThreadSafety(t *testing.T) { From 10e1e9968377bb939603c678ee3964ce344b921d Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Thu, 4 Dec 2025 16:56:34 +0000 Subject: [PATCH 4/5] improve test coverage --- cmd/any2anexoj-cli/pretty_printer_test.go | 83 +++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/cmd/any2anexoj-cli/pretty_printer_test.go b/cmd/any2anexoj-cli/pretty_printer_test.go index 06ab7d0..48cb3ca 100644 --- a/cmd/any2anexoj-cli/pretty_printer_test.go +++ b/cmd/any2anexoj-cli/pretty_printer_test.go @@ -1 +1,84 @@ package main + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/nmoniz/any2anexoj/internal" + "github.com/shopspring/decimal" +) + +func TestPrettyPrinter_Render(t *testing.T) { + // Create test data + aw := internal.NewAggregatorWriter() + ctx := context.Background() + + // Add some sample report items + err := aw.Write(ctx, 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), + }) + 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, // United Kingdom + AssetCountry: 840, // United States + 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) + } + + // Create English localizer + localizer, err := NewLocalizer("en") + if err != nil { + t.Fatalf("failed to create localizer: %v", err) + } + + // Create pretty printer with buffer + var buf bytes.Buffer + pp := NewPrettyPrinter(&buf, localizer) + + // Render the table + pp.Render(aw) + + // Get the output + got := buf.String() + + // 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 │ │ │ +├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤ +│ 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 € │ │ +└───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘ +` + + // Compare output + if got != want { + t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want) + } +} From 0d97432c6cb2694953547fb90be3658f9435fe72 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Thu, 4 Dec 2025 16:59:20 +0000 Subject: [PATCH 5/5] improved error handling on translation --- cmd/any2anexoj-cli/localizer.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/any2anexoj-cli/localizer.go b/cmd/any2anexoj-cli/localizer.go index 18691a2..a8cf756 100644 --- a/cmd/any2anexoj-cli/localizer.go +++ b/cmd/any2anexoj-cli/localizer.go @@ -4,6 +4,7 @@ import ( "embed" "encoding/json" "fmt" + "log/slog" "github.com/nicksnyder/go-i18n/v2/i18n" "golang.org/x/text/language" @@ -38,9 +39,14 @@ func NewLocalizer(lang string) (*Localizer, error) { } func (t Localizer) Translate(key string, count int, values map[string]any) string { - return t.MustLocalize(&i18n.LocalizeConfig{ + txt, err := t.Localize(&i18n.LocalizeConfig{ MessageID: key, TemplateData: values, PluralCount: count, }) + if err != nil { + slog.Error("failed to translate message", slog.Any("err", err)) + return "" + } + return txt }