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) - } - }) - } -}