Compare commits
43 Commits
f49377a6dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4626f08b9c | |||
| 0d97432c6c | |||
| 10e1e99683 | |||
| 38db50b879 | |||
| 21147954cb | |||
| b12c519fdb | |||
| f651ce8597 | |||
| a19b02f1f6 | |||
| a079ea8ae5 | |||
| 5a0fb8b6aa | |||
| f2bfefcea5 | |||
| d415fcd752 | |||
| 950143e17d | |||
| 066b5b76a8 | |||
| 2a3f13e91a | |||
| 0b6b35e736 | |||
| 5060fca7be | |||
| 4a2884a0df | |||
| 64bbf8d129 | |||
| 57ee768006 | |||
| 0d3e3df9e7 | |||
| 5f13ebaf6a | |||
| 70466b7886 | |||
| bd101ce46a | |||
| 93f1dab3d2 | |||
| c323047175 | |||
| 8c784f3b74 | |||
| a1ea13ff2f | |||
| 6b5552b559 | |||
| 23614d51db | |||
| ef0a4476a7 | |||
| b4b12ad625 | |||
| 1106705eb2 | |||
| f716c2e897 | |||
| ef350b2659 | |||
| 914ead1681 | |||
| c363652f49 | |||
| a347443c81 | |||
| 89bcd15b17 | |||
| 9ba5116c03 | |||
| 1c3fd0397a | |||
| 961f0eed38 | |||
| 290593a9aa |
32
.gitea/workflows/badges.yml
Normal file
32
.gitea/workflows/badges.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Badges
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
coveralls:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.25
|
||||
|
||||
- name: Run Unit tests with coverage
|
||||
run: |
|
||||
go test -covermode atomic -coverprofile=coverage.out ./...
|
||||
grep -v -E "(main|_gen).go" coverage.out > coverage.filtered.out
|
||||
mv coverage.filtered.out coverage.out
|
||||
|
||||
- name: Install goveralls
|
||||
run: go install github.com/mattn/goveralls@v0.0.12
|
||||
|
||||
- name: Send coverage
|
||||
env:
|
||||
COVERALLS_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN}}
|
||||
run: goveralls -coverprofile=coverage.out -service=github
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Generate check
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
echo "has_gen_changes=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
check-generate:
|
||||
verify-generate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.has_gen_changes == 'true'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests
|
||||
name: Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
echo "has_go_changes=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
tests:
|
||||
run-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.has_go_changes == 'true'
|
||||
@@ -37,5 +37,6 @@ jobs:
|
||||
with:
|
||||
go-version: 1.25
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v ./...
|
||||
- name: Run Unit tests
|
||||
run: |
|
||||
go test -race -covermode atomic -coverprofile=coverage.out ./...
|
||||
16
README.md
16
README.md
@@ -1,15 +1,17 @@
|
||||
# any2anexoj
|
||||
|
||||

|
||||
[](https://goreportcard.com/report/github.com/nmoniz/any2anexoj)
|
||||
[](https://coveralls.io/github/nmoniz/any2anexoj?branch=main)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.ibb.co/0yRtwq2C/0-FBA40-FD-D97-A-4-AFB-8618-49582-DB98-F3-C.png" alt="Screenshot" border="0">
|
||||
</p>
|
||||
|
||||
This tool converts the statements from known brokers and exchanges into a format compatible with section 9 from the Portuguese IRS form: [Mod_3_anexo_j](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/modelos_formularios/irs/Documents/Mod_3_anexo_J.pdf)
|
||||
|
||||
> [!WARNING]
|
||||
> Although I made significant efforts to ensure the correctness of the calculations you should verify any outputs produced by this tool on your own or with a certified accountant.
|
||||
|
||||
> [!NOTE]
|
||||
> This tool is in early stages of development. Use at your own risk!
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
@@ -21,3 +23,9 @@ go install github.com/nmoniz/any2anexoj/cmd/any2anexoj-cli@latest
|
||||
```bash
|
||||
cat statement.csv | any2anexoj-cli --platform=tranding212
|
||||
```
|
||||
|
||||
## Rounding
|
||||
|
||||
All Euro values are rounded to cents (2 decimal places) but internal calculations use the statement values with full precision.
|
||||
There are no explicit rules or details about how to round Euro values in Anexo J.
|
||||
This application rounds according to `Portaria n.º 1180/2001, art. 2.º, alínea c) e d)` (Ministerial Order / Government Order) examples, which imply we should round to the 2nd decimal place by rounding up (ceiling) or down (floor) depending on whether the third decimal place is ≥ 5 or < 5, respectively.
|
||||
|
||||
52
cmd/any2anexoj-cli/localizer.go
Normal file
52
cmd/any2anexoj-cli/localizer.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"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 {
|
||||
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 "<ERROR>"
|
||||
}
|
||||
return txt
|
||||
}
|
||||
24
cmd/any2anexoj-cli/localizer_test.go
Normal file
24
cmd/any2anexoj-cli/localizer_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,28 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
"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) },
|
||||
"trading212": func() internal.RecordReader {
|
||||
return trading212.NewRecordReader(os.Stdin, internal.NewOpenFIGI(&http.Client{Timeout: 5 * time.Second}))
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -29,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()
|
||||
|
||||
@@ -51,7 +63,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)
|
||||
@@ -62,7 +74,14 @@ func run(ctx context.Context, platform string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
writer.Render()
|
||||
loc, err := NewLocalizer(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create localizer: %w", err)
|
||||
}
|
||||
|
||||
printer := NewPrettyPrinter(os.Stdout, loc)
|
||||
|
||||
printer.Render(writer)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
122
cmd/any2anexoj-cli/pretty_printer.go
Normal file
122
cmd/any2anexoj-cli/pretty_printer.go
Normal file
@@ -0,0 +1,122 @@
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
84
cmd/any2anexoj-cli/pretty_printer_test.go
Normal file
84
cmd/any2anexoj-cli/pretty_printer_test.go
Normal file
@@ -0,0 +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)
|
||||
}
|
||||
}
|
||||
46
cmd/any2anexoj-cli/translations/en.json
Normal file
46
cmd/any2anexoj-cli/translations/en.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
46
cmd/any2anexoj-cli/translations/pt.json
Normal file
46
cmd/any2anexoj-cli/translations/pt.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
12
go.mod
12
go.mod
@@ -3,20 +3,22 @@ module github.com/nmoniz/any2anexoj
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/biter777/countries v1.7.5
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/spf13/pflag v1.0.10
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/sync v0.18.0
|
||||
github.com/biter777/countries v1.7.5
|
||||
golang.org/x/time v0.14.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.2 // indirect
|
||||
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
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // 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
|
||||
)
|
||||
|
||||
|
||||
9
go.sum
9
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=
|
||||
@@ -17,9 +19,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
@@ -30,6 +31,10 @@ 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=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
78
internal/aggregator_writer.go
Normal file
78
internal/aggregator_writer.go
Normal file
@@ -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
|
||||
}
|
||||
288
internal/aggregator_writer_test.go
Normal file
288
internal/aggregator_writer_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
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 5 {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func TestFillerQueue(t *testing.T) {
|
||||
var rq FillerQueue
|
||||
|
||||
if rq.Len() != 0 {
|
||||
t.Fatalf("zero value should have zero lenght")
|
||||
t.Fatalf("zero value should have zero length")
|
||||
}
|
||||
|
||||
_, ok := rq.Pop()
|
||||
@@ -38,12 +38,12 @@ func TestFillerQueue(t *testing.T) {
|
||||
|
||||
rq.Push(NewFiller(newRecord()))
|
||||
if rq.Len() != 1 {
|
||||
t.Fatalf("pushing 1st record should result in lenght of 1")
|
||||
t.Fatalf("pushing 1st record should result in length of 1")
|
||||
}
|
||||
|
||||
rq.Push(NewFiller(newRecord()))
|
||||
if rq.Len() != 2 {
|
||||
t.Fatalf("pushing 2nd record should result in lenght of 2")
|
||||
t.Fatalf("pushing 2nd record should result in length of 2")
|
||||
}
|
||||
|
||||
peekFiller, ok := rq.Peek()
|
||||
@@ -85,7 +85,7 @@ func TestFillerQueueNilReceiver(t *testing.T) {
|
||||
var rq *FillerQueue
|
||||
|
||||
if rq.Len() > 0 {
|
||||
t.Fatalf("nil receiver should have zero lenght")
|
||||
t.Fatalf("nil receiver should have zero length")
|
||||
}
|
||||
|
||||
_, ok := rq.Peek()
|
||||
|
||||
@@ -220,6 +220,44 @@ func (c *MockRecordFeesCall) DoAndReturn(f func() decimal.Decimal) *MockRecordFe
|
||||
return c
|
||||
}
|
||||
|
||||
// Nature mocks base method.
|
||||
func (m *MockRecord) Nature() internal.Nature {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Nature")
|
||||
ret0, _ := ret[0].(internal.Nature)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Nature indicates an expected call of Nature.
|
||||
func (mr *MockRecordMockRecorder) Nature() *MockRecordNatureCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Nature", reflect.TypeOf((*MockRecord)(nil).Nature))
|
||||
return &MockRecordNatureCall{Call: call}
|
||||
}
|
||||
|
||||
// MockRecordNatureCall wrap *gomock.Call
|
||||
type MockRecordNatureCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockRecordNatureCall) Return(arg0 internal.Nature) *MockRecordNatureCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockRecordNatureCall) Do(f func() internal.Nature) *MockRecordNatureCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockRecordNatureCall) DoAndReturn(f func() internal.Nature) *MockRecordNatureCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// Price mocks base method.
|
||||
func (m *MockRecord) Price() decimal.Decimal {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
22
internal/nature.go
Normal file
22
internal/nature.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package internal
|
||||
|
||||
type Nature string
|
||||
|
||||
const (
|
||||
// NatureUnknown is the zero value of Nature type
|
||||
NatureUnknown Nature = ""
|
||||
|
||||
// NatureG01 describes selling of stocks per table VII: Alienação onerosa de ações/partes sociais
|
||||
NatureG01 Nature = "G01"
|
||||
|
||||
// NatureG20 describes selling units in investment funds (including ETFs) as per table VII:
|
||||
// Resgates ou alienação de unidades de participação ou liquidação de fundos de investimento
|
||||
NatureG20 Nature = "G20"
|
||||
)
|
||||
|
||||
func (n Nature) String() string {
|
||||
if n == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return string(n)
|
||||
}
|
||||
38
internal/nature_test.go
Normal file
38
internal/nature_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
func TestNature_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nature internal.Nature
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "return unknown",
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "return G01",
|
||||
nature: internal.NatureG01,
|
||||
want: "G01",
|
||||
},
|
||||
{
|
||||
name: "return G20",
|
||||
nature: internal.NatureG20,
|
||||
want: "G20",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.nature.String()
|
||||
if tt.want != got {
|
||||
t.Fatalf("want %q but got %q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
126
internal/open_figi.go
Normal file
126
internal/open_figi.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/biter777/countries"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// OpenFIGI is a small adapter for the openfigi.com api
|
||||
type OpenFIGI struct {
|
||||
client *http.Client
|
||||
mappingLimiter *rate.Limiter
|
||||
|
||||
mu sync.RWMutex
|
||||
// TODO: there's no eviction policy at the moment as this is only used by short-lived application
|
||||
// which processes a relatively small amount of records. We need to consider using an external
|
||||
// cache lib (like golang-lru or go-cache) if this becomes a problem or implement this ourselves.
|
||||
securityTypeCache map[string]string
|
||||
}
|
||||
|
||||
func NewOpenFIGI(c *http.Client) *OpenFIGI {
|
||||
return &OpenFIGI{
|
||||
client: c,
|
||||
mappingLimiter: rate.NewLimiter(rate.Every(time.Minute), 25), // https://www.openfigi.com/api/documentation#rate-limits
|
||||
|
||||
securityTypeCache: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (of *OpenFIGI) SecurityTypeByISIN(ctx context.Context, isin string) (string, error) {
|
||||
of.mu.RLock()
|
||||
if secType, ok := of.securityTypeCache[isin]; ok {
|
||||
of.mu.RUnlock()
|
||||
return secType, nil
|
||||
}
|
||||
of.mu.RUnlock()
|
||||
|
||||
of.mu.Lock()
|
||||
defer of.mu.Unlock()
|
||||
|
||||
// we check again because there could be more than one concurrent cache miss and we want only one
|
||||
// of them to result in an actual request. When the first one releases the lock the following
|
||||
// reads will hit the cache.
|
||||
if secType, ok := of.securityTypeCache[isin]; ok {
|
||||
return secType, nil
|
||||
}
|
||||
|
||||
if len(isin) != 12 || countries.ByName(isin[:2]) == countries.Unknown {
|
||||
return "", fmt.Errorf("invalid ISIN: %s", isin)
|
||||
}
|
||||
|
||||
rawBody, err := json.Marshal([]mappingRequestBody{{
|
||||
IDType: "ID_ISIN",
|
||||
IDValue: isin,
|
||||
}})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal mapping request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openfigi.com/v3/mapping", bytes.NewBuffer(rawBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create mapping request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
err = of.mappingLimiter.Wait(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wait for mapping request capacity: %w", err)
|
||||
}
|
||||
|
||||
res, err := of.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("make mapping request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("bad mapping response status code: %s", res.Status)
|
||||
}
|
||||
|
||||
var resBody []mappingResponseBody
|
||||
err = json.NewDecoder(res.Body).Decode(&resBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if len(resBody) == 0 {
|
||||
return "", fmt.Errorf("missing top-level elements")
|
||||
}
|
||||
|
||||
if len(resBody[0].Data) == 0 {
|
||||
return "", fmt.Errorf("missing data elements")
|
||||
}
|
||||
|
||||
// It is not possible that an isin is assign to different security types, therefore we can assume
|
||||
// all entries have the same securityType value.
|
||||
secType := resBody[0].Data[0].SecurityType
|
||||
if secType == "" {
|
||||
return "", fmt.Errorf("empty security type returned for ISIN: %s", isin)
|
||||
}
|
||||
|
||||
of.securityTypeCache[isin] = secType
|
||||
|
||||
return secType, nil
|
||||
}
|
||||
|
||||
type mappingRequestBody struct {
|
||||
IDType string `json:"idType"`
|
||||
IDValue string `json:"idValue"`
|
||||
}
|
||||
|
||||
type mappingResponseBody struct {
|
||||
Data []struct {
|
||||
FIGI string `json:"figi"`
|
||||
SecurityType string `json:"securityType"`
|
||||
Ticker string `json:"ticker"`
|
||||
} `json:"data"`
|
||||
}
|
||||
182
internal/open_figi_test.go
Normal file
182
internal/open_figi_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nmoniz/any2anexoj/internal"
|
||||
)
|
||||
|
||||
func TestOpenFIGI_SecurityTypeByISIN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string // description of this test case
|
||||
client *http.Client
|
||||
isin string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "all good",
|
||||
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"figi":"BBG000BJJR23","name":"AIRBUS SE","ticker":"EADSF","exchCode":"US","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"},{"figi":"BBG000BJJXJ2","name":"AIRBUS SE","ticker":"EADSF","exchCode":"PQ","compositeFIGI":"BBG000BJJR23","securityType":"Common Stock","marketSector":"Equity","shareClassFIGI":"BBG001S8TFZ6","securityType2":"Common Stock","securityDescription":"EADSF"}]}]`)),
|
||||
}, nil
|
||||
}),
|
||||
isin: "NL0000235190",
|
||||
want: "Common Stock",
|
||||
},
|
||||
{
|
||||
name: "bad status code",
|
||||
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusTooManyRequests),
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
}, nil
|
||||
}),
|
||||
isin: "NL0000235190",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad json",
|
||||
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"bad": "json"}`)),
|
||||
}, nil
|
||||
}),
|
||||
isin: "NL0000235190",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty top-level",
|
||||
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[]`)),
|
||||
}, nil
|
||||
}),
|
||||
isin: "NL0000235190",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty data elements",
|
||||
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[]}]`)),
|
||||
}, nil
|
||||
}),
|
||||
isin: "NL0000235190",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty securityType",
|
||||
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":""}]}]`)),
|
||||
}, nil
|
||||
}),
|
||||
isin: "NL0000235190",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "client error",
|
||||
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("boom")
|
||||
}),
|
||||
isin: "NL0000235190",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty isin",
|
||||
client: NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatalf("should not make api request")
|
||||
return nil, nil
|
||||
}),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
of := internal.NewOpenFIGI(tt.client)
|
||||
|
||||
got, gotErr := of.SecurityTypeByISIN(context.Background(), tt.isin)
|
||||
if gotErr != nil {
|
||||
if !tt.wantErr {
|
||||
t.Errorf("want success but failed: %v", gotErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatal("want error but none")
|
||||
}
|
||||
|
||||
if tt.want != got {
|
||||
t.Fatalf("want security type to be %s but got %s", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFIGI_SecurityTypeByISIN_Cache(t *testing.T) {
|
||||
var alreadyCalled bool
|
||||
c := NewTestClient(t, func(req *http.Request) (*http.Response, error) {
|
||||
if alreadyCalled {
|
||||
t.Fatalf("want requests to be cached")
|
||||
}
|
||||
|
||||
alreadyCalled = true
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"data":[{"securityType":"Common Stock"}]}]`)),
|
||||
}, nil
|
||||
})
|
||||
|
||||
of := internal.NewOpenFIGI(c)
|
||||
|
||||
got, gotErr := of.SecurityTypeByISIN(t.Context(), "NL0000235190")
|
||||
if gotErr != nil {
|
||||
t.Fatalf("want 1st success call but got error: %v", gotErr)
|
||||
}
|
||||
|
||||
if got != "Common Stock" {
|
||||
t.Fatalf("want 1st securityType to be %q but got %q", "Common Stock", got)
|
||||
}
|
||||
|
||||
got, gotErr = of.SecurityTypeByISIN(t.Context(), "NL0000235190")
|
||||
if gotErr != nil {
|
||||
t.Fatalf("want 2nd success call but got error: %v", gotErr)
|
||||
}
|
||||
|
||||
if got != "Common Stock" {
|
||||
t.Fatalf("want 2nd securityType to be %q but got %q", "Common Stock", got)
|
||||
}
|
||||
}
|
||||
|
||||
type RoundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func NewTestClient(t testing.TB, fn RoundTripFunc) *http.Client {
|
||||
t.Helper()
|
||||
|
||||
return &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: fn,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type Record interface {
|
||||
Symbol() string
|
||||
Nature() Nature
|
||||
BrokerCountry() int64
|
||||
AssetCountry() int64
|
||||
Side() Side
|
||||
@@ -29,6 +30,7 @@ type RecordReader interface {
|
||||
|
||||
type ReportItem struct {
|
||||
Symbol string
|
||||
Nature Nature
|
||||
BrokerCountry int64
|
||||
AssetCountry int64
|
||||
BuyValue decimal.Decimal
|
||||
@@ -115,7 +117,8 @@ func processRecord(ctx context.Context, q *FillerQueue, rec Record, writer Repor
|
||||
SellValue: sellValue,
|
||||
SellTimestamp: rec.Timestamp(),
|
||||
Fees: buy.Fees().Add(rec.Fees()),
|
||||
Taxes: buy.Taxes().Add(rec.Fees()),
|
||||
Taxes: buy.Taxes().Add(rec.Taxes()),
|
||||
Nature: buy.Nature(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write report item: %w", err)
|
||||
|
||||
@@ -60,6 +60,7 @@ func mockRecord(ctrl *gomock.Controller, price, quantity float64, side internal.
|
||||
rec.EXPECT().Timestamp().Return(ts).AnyTimes()
|
||||
rec.EXPECT().Fees().Return(decimal.Decimal{}).AnyTimes()
|
||||
rec.EXPECT().Taxes().Return(decimal.Decimal{}).AnyTimes()
|
||||
rec.EXPECT().Nature().Return(internal.NatureG01).AnyTimes()
|
||||
return rec
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,12 @@ func (d Side) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// IsBuy returns true if the s == SideBuy
|
||||
func (d Side) IsBuy() bool {
|
||||
return d == SideBuy
|
||||
}
|
||||
|
||||
// IsSell returns true if the s == SideSell
|
||||
func (d Side) IsSell() bool {
|
||||
return d == SideSell
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"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.AppendHeader(table.Row{"", "", "Realisation", "Realisation", "Realisation", "Realisation", "Aquisition", "Aquisition", "Aquisition", "Aquisition", "", "", ""}, table.RowConfig{AutoMerge: true})
|
||||
t.AppendHeader(table.Row{"Source Country", "Code", "Year", "Month", "Day", "Value", "Year", "Month", "Day", "Value", "Expenses", "Payed 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)
|
||||
tw.totalSpent = tw.totalSpent.Add(ri.BuyValue)
|
||||
tw.totalFees = tw.totalFees.Add(ri.Fees)
|
||||
tw.totalTaxes = tw.totalTaxes.Add(ri.Taxes)
|
||||
|
||||
tw.table.AppendRow(table.Row{ri.AssetCountry, "G!!", ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue, ri.BuyTimestamp.Year(), ri.BuyTimestamp.Month(), ri.BuyTimestamp.Day(), ri.BuyValue, ri.Fees, ri.Taxes, ri.BrokerCountry})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tw *TableWriter) Render() {
|
||||
tw.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", tw.totalEarned, "", "", "", tw.totalSpent, tw.totalFees, tw.totalTaxes}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight})
|
||||
tw.table.Render()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/biter777/countries"
|
||||
@@ -15,18 +17,25 @@ import (
|
||||
|
||||
type Record struct {
|
||||
symbol string
|
||||
timestamp time.Time
|
||||
side internal.Side
|
||||
quantity decimal.Decimal
|
||||
price decimal.Decimal
|
||||
timestamp time.Time
|
||||
fees decimal.Decimal
|
||||
taxes decimal.Decimal
|
||||
|
||||
// natureGetter allows us to defer the operation of figuring out the nature to only when/if needed.
|
||||
natureGetter func() internal.Nature
|
||||
}
|
||||
|
||||
func (r Record) Symbol() string {
|
||||
return r.symbol
|
||||
}
|
||||
|
||||
func (r Record) Timestamp() time.Time {
|
||||
return r.timestamp
|
||||
}
|
||||
|
||||
func (r Record) BrokerCountry() int64 {
|
||||
return int64(Country)
|
||||
}
|
||||
@@ -47,10 +56,6 @@ func (r Record) Price() decimal.Decimal {
|
||||
return r.price
|
||||
}
|
||||
|
||||
func (r Record) Timestamp() time.Time {
|
||||
return r.timestamp
|
||||
}
|
||||
|
||||
func (r Record) Fees() decimal.Decimal {
|
||||
return r.fees
|
||||
}
|
||||
@@ -59,13 +64,19 @@ func (r Record) Taxes() decimal.Decimal {
|
||||
return r.taxes
|
||||
}
|
||||
|
||||
type RecordReader struct {
|
||||
reader *csv.Reader
|
||||
func (r Record) Nature() internal.Nature {
|
||||
return r.natureGetter()
|
||||
}
|
||||
|
||||
func NewRecordReader(r io.Reader) *RecordReader {
|
||||
type RecordReader struct {
|
||||
reader *csv.Reader
|
||||
figi *internal.OpenFIGI
|
||||
}
|
||||
|
||||
func NewRecordReader(r io.Reader, f *internal.OpenFIGI) *RecordReader {
|
||||
return &RecordReader{
|
||||
reader: csv.NewReader(r),
|
||||
figi: f,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +87,7 @@ const (
|
||||
LimitSell = "limit sell"
|
||||
)
|
||||
|
||||
func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) {
|
||||
func (rr RecordReader) ReadRecord(ctx context.Context) (internal.Record, error) {
|
||||
for {
|
||||
raw, err := rr.reader.Read()
|
||||
if err != nil {
|
||||
@@ -110,43 +121,64 @@ func (rr RecordReader) ReadRecord(_ context.Context) (internal.Record, error) {
|
||||
return Record{}, fmt.Errorf("parse record timestamp: %w", err)
|
||||
}
|
||||
|
||||
convertionFee, err := parseOptinalDecimal(raw[16])
|
||||
conversionFee, err := parseOptionalDecimal(raw[16])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record convertion fee: %w", err)
|
||||
return Record{}, fmt.Errorf("parse record conversion fee: %w", err)
|
||||
}
|
||||
|
||||
stampDutyTax, err := parseOptinalDecimal(raw[14])
|
||||
stampDutyTax, err := parseOptionalDecimal(raw[14])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record stamp duty tax: %w", err)
|
||||
}
|
||||
|
||||
frenchTxTax, err := parseOptinalDecimal(raw[18])
|
||||
frenchTxTax, err := parseOptionalDecimal(raw[18])
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("parse record french transaction tax: %w", err)
|
||||
}
|
||||
|
||||
return Record{
|
||||
symbol: raw[2],
|
||||
side: side,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
timestamp: ts,
|
||||
fees: convertionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
symbol: raw[2],
|
||||
side: side,
|
||||
quantity: qant,
|
||||
price: price,
|
||||
fees: conversionFee,
|
||||
taxes: stampDutyTax.Add(frenchTxTax),
|
||||
timestamp: ts,
|
||||
natureGetter: figiNatureGetter(ctx, rr.figi, raw[2]),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func figiNatureGetter(ctx context.Context, of *internal.OpenFIGI, isin string) func() internal.Nature {
|
||||
return sync.OnceValue(func() internal.Nature {
|
||||
secType, err := of.SecurityTypeByISIN(ctx, isin)
|
||||
if err != nil {
|
||||
slog.Error("failed to get security type by ISIN", slog.Any("err", err), slog.String("isin", isin))
|
||||
return internal.NatureUnknown
|
||||
}
|
||||
|
||||
switch secType {
|
||||
case "Common Stock":
|
||||
return internal.NatureG01
|
||||
case "ETP":
|
||||
return internal.NatureG20
|
||||
default:
|
||||
slog.Error("got unsupported security type for ISIN", slog.String("isin", isin), slog.String("securityType", secType))
|
||||
return internal.NatureUnknown
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// parseFloat attempts to parse a string using a standard precision and rounding mode.
|
||||
// Using this function helps avoid issues around converting values due to sligh parameter changes.
|
||||
// Using this function helps avoid issues around converting values due to minor parameter changes.
|
||||
func parseDecimal(s string) (decimal.Decimal, error) {
|
||||
return decimal.NewFromString(s)
|
||||
}
|
||||
|
||||
// parseOptinalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of
|
||||
// parseOptionalDecimal behaves the same as parseDecimal but returns 0 when len(s) is 0 instead of
|
||||
// error.
|
||||
// Using this function helps avoid issues around converting values due to sligh parameter changes.
|
||||
func parseOptinalDecimal(s string) (decimal.Decimal, error) {
|
||||
// Using this function helps avoid issues around converting values due to minor parameter changes.
|
||||
func parseOptionalDecimal(s string) (decimal.Decimal, error) {
|
||||
if len(s) == 0 {
|
||||
return decimal.Decimal{}, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package trading212
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,74 +27,86 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "well-formed buy",
|
||||
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,SYM123456ABXY,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`Market buy,2025-07-03 10:44:29,XX1234567890,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.3690000000,USD,1.17995999,,"EUR",15.25,"EUR",0.25,"EUR",0.02,"EUR",,`),
|
||||
want: Record{
|
||||
symbol: "SYM123456ABXY",
|
||||
side: internal.SideBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
symbol: "XX1234567890",
|
||||
side: internal.SideBuy,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.3690000000"),
|
||||
timestamp: time.Date(2025, 7, 3, 10, 44, 29, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.25"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "well-formed sell",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",0.1,"EUR"`),
|
||||
r: bytes.NewBufferString(`Market sell,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",0.1,"EUR"`),
|
||||
want: Record{
|
||||
symbol: "IE000GA3D489",
|
||||
side: internal.SideSell,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.9999999999"),
|
||||
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.1"),
|
||||
symbol: "XX1234567890",
|
||||
side: internal.SideSell,
|
||||
quantity: ShouldParseDecimal(t, "2.4387014200"),
|
||||
price: ShouldParseDecimal(t, "7.9999999999"),
|
||||
timestamp: time.Date(2025, 8, 4, 11, 45, 30, 0, time.UTC),
|
||||
fees: ShouldParseDecimal(t, "0.02"),
|
||||
taxes: ShouldParseDecimal(t, "0.1"),
|
||||
natureGetter: func() internal.Nature { return internal.NatureG01 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed side",
|
||||
r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`Aljksdaf Balsjdkf,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty side",
|
||||
r: bytes.NewBufferString(`,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed qantity",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,0x1234,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty qantity",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed price",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,0b101010,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,0b101010,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty price",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:39,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed fees",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,BAD,"EUR",0.1,"EUR"`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed taxes",
|
||||
r: bytes.NewBufferString(`Market sell,2025-08-04 11:45:30,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",BAD,"EUR"`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed timestamp",
|
||||
r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`Market sell,2006-01-02T15:04:05Z07:00,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty timestamp",
|
||||
r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Brocoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
r: bytes.NewBufferString(`Market sell,,IE000GA3D489,ABXY,"Aspargus Broccoli",EOF987654321,2.4387014200,7.9999999999,USD,1.17995999,,"EUR",15.25,"EUR",,,0.02,"EUR",,`),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rr := NewRecordReader(tt.r)
|
||||
rr := NewRecordReader(tt.r, NewFigiClientSecurityTypeStub(t, "Common Stock"))
|
||||
got, gotErr := rr.ReadRecord(t.Context())
|
||||
if gotErr != nil {
|
||||
if !tt.wantErr {
|
||||
@@ -132,6 +146,48 @@ func TestRecordReader_ReadRecord(t *testing.T) {
|
||||
if got.Taxes().Cmp(tt.want.taxes) != 0 {
|
||||
t.Fatalf("want taxes %v but got %v", tt.want.taxes, got.Taxes())
|
||||
}
|
||||
|
||||
if tt.want.natureGetter != nil && tt.want.Nature() != got.Nature() {
|
||||
t.Fatalf("want nature %v but got %v", tt.want.Nature(), got.Nature())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_figiNatureGetter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string // description of this test case
|
||||
of *internal.OpenFIGI
|
||||
want internal.Nature
|
||||
}{
|
||||
{
|
||||
name: "Common Stock translates to G01",
|
||||
of: NewFigiClientSecurityTypeStub(t, "Common Stock"),
|
||||
want: internal.NatureG01,
|
||||
},
|
||||
{
|
||||
name: "ETP translates to G20",
|
||||
of: NewFigiClientSecurityTypeStub(t, "ETP"),
|
||||
want: internal.NatureG20,
|
||||
},
|
||||
{
|
||||
name: "Other translates to Unknown",
|
||||
of: NewFigiClientSecurityTypeStub(t, "Other"),
|
||||
want: internal.NatureUnknown,
|
||||
},
|
||||
{
|
||||
name: "Request fails",
|
||||
of: NewFigiClientErrorStub(t, fmt.Errorf("boom")),
|
||||
want: internal.NatureUnknown,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
getter := figiNatureGetter(t.Context(), tt.of, "IR1234567890")
|
||||
got := getter()
|
||||
if tt.want != got {
|
||||
t.Errorf("want %v but got %v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -145,3 +201,40 @@ func ShouldParseDecimal(t testing.TB, sf string) decimal.Decimal {
|
||||
}
|
||||
return bf
|
||||
}
|
||||
|
||||
type RoundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func NewFigiClientSecurityTypeStub(t testing.TB, securityType string) *internal.OpenFIGI {
|
||||
t.Helper()
|
||||
|
||||
c := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`[{"data":[{"securityType":%q}]}]`, securityType))),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
return internal.NewOpenFIGI(c)
|
||||
}
|
||||
|
||||
func NewFigiClientErrorStub(t testing.TB, err error) *internal.OpenFIGI {
|
||||
t.Helper()
|
||||
|
||||
c := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, err
|
||||
}),
|
||||
}
|
||||
|
||||
return internal.NewOpenFIGI(c)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user