Skip to content

Commit a2597b2

Browse files
authored
Currency (#4)
* feat: base currency * feat: basic currencies * feat: exclude from report * fix: migrations
1 parent 1eaa05e commit a2597b2

21 files changed

+643
-32
lines changed

.github/workflows/pull_request.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
- run: wget -O /usr/bin/mockgen https://github.com/skynet2/mock/releases/latest/download/mockgen && chmod 777 /usr/bin/mockgen
4141
- run: make generate
4242
- run: environment=ci go test -json -coverprofile=/root/coverage_temp.txt -covermode=atomic ./... > /root/test.json
43-
- run: cat /root/coverage_temp.txt | grep -v "_mock.go" | grep -v "_mocks.go" | grep -v "_mocks_test.go" | grep -v "_mock_test.go" | grep -v "main.go" > /root/coverage.txt || true
43+
- run: cat /root/coverage_temp.txt | grep -v "_mock.go" | grep -v "migrations.go" | grep -v "_mocks.go" | grep -v "_mocks_test.go" | grep -v "_mock_test.go" | grep -v "main.go" | grep -v "testingutils" | grep -v "boilerplate" > /root/coverage.txt || true
4444
- name: Upload coverage report
4545
uses: codecov/codecov-action@v3
4646
with:

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Go Money
2+
3+
![build workflow](https://github.com/ft-t/go-money/actions/workflows/general.yaml/badge.svg?branch=master)
4+
[![codecov](https://codecov.io/gh/ft-t/go-money/graph/badge.svg?token=pas79tP0Dr)](https://codecov.io/gh/ft-t/go-money)
5+
[![go-report](https://img.shields.io/badge/go%20report-A+-brightgreen.svg?style=flat)](https://img.shields.io/badge/go%20report-A+-brightgreen.svg?style=flat)
6+
[![PkgGoDev](https://pkg.go.dev/badge/github.com/ft-t/go-money)](https://pkg.go.dev/github.com/ft-t/go-money?tab=doc)

cmd/server/currency.go

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package main
2+
3+
import (
4+
"connectrpc.com/connect"
5+
"context"
6+
"github.com/ft-t/go-money-pb/gen/gomoneypb/currency/v1"
7+
"github.com/ft-t/go-money-pb/gen/gomoneypb/currency/v1/currencyv1connect"
8+
"github.com/ft-t/go-money/pkg/boilerplate"
9+
"github.com/shopspring/decimal"
10+
)
11+
12+
type CurrencyApi struct {
13+
currencySvc CurrencySvc
14+
converterSvc ConverterSvc
15+
decimalSvc DecimalSvc
16+
}
17+
18+
func NewCurrencyApi(
19+
mux *boilerplate.DefaultGrpcServer,
20+
currencySvc CurrencySvc,
21+
converterSvc ConverterSvc,
22+
decimalSvc DecimalSvc,
23+
) (*CurrencyApi, error) {
24+
res := &CurrencyApi{
25+
currencySvc: currencySvc,
26+
converterSvc: converterSvc,
27+
decimalSvc: decimalSvc,
28+
}
29+
30+
mux.GetMux().Handle(
31+
currencyv1connect.NewCurrencyServiceHandler(res, mux.GetDefaultHandlerOptions()...),
32+
)
33+
34+
return res, nil
35+
}
36+
37+
func (a *CurrencyApi) Exchange(
38+
ctx context.Context,
39+
c *connect.Request[currencyv1.ExchangeRequest],
40+
) (*connect.Response[currencyv1.ExchangeResponse], error) {
41+
amount, err := decimal.NewFromString(c.Msg.Amount)
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
resp, err := a.converterSvc.Convert(ctx, c.Msg.FromCurrency, c.Msg.ToCurrency, amount)
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
return connect.NewResponse(&currencyv1.ExchangeResponse{
52+
Amount: a.decimalSvc.ToString(ctx, resp, c.Msg.ToCurrency),
53+
}), nil
54+
}
55+
56+
func (a *CurrencyApi) GetCurrencies(
57+
ctx context.Context,
58+
c *connect.Request[currencyv1.GetCurrenciesRequest],
59+
) (*connect.Response[currencyv1.GetCurrenciesResponse], error) {
60+
resp, err := a.currencySvc.GetCurrencies(ctx, c.Msg)
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
return connect.NewResponse(resp), nil
66+
}
67+
68+
func (a *CurrencyApi) CreateCurrency(
69+
ctx context.Context,
70+
c *connect.Request[currencyv1.CreateCurrencyRequest],
71+
) (*connect.Response[currencyv1.CreateCurrencyResponse], error) {
72+
// todo auth
73+
74+
resp, err := a.currencySvc.CreateCurrency(ctx, c.Msg)
75+
if err != nil {
76+
return nil, err
77+
}
78+
79+
return connect.NewResponse(resp), nil
80+
}
81+
82+
func (a *CurrencyApi) UpdateCurrency(
83+
ctx context.Context,
84+
c *connect.Request[currencyv1.UpdateCurrencyRequest],
85+
) (*connect.Response[currencyv1.UpdateCurrencyResponse], error) {
86+
// todo auth
87+
88+
resp, err := a.currencySvc.UpdateCurrency(ctx, c.Msg)
89+
if err != nil {
90+
return nil, err
91+
}
92+
93+
return connect.NewResponse(resp), nil
94+
}

cmd/server/interfaces.go

+32
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"context"
55
accountsv1 "github.com/ft-t/go-money-pb/gen/gomoneypb/accounts/v1"
66
configurationv1 "github.com/ft-t/go-money-pb/gen/gomoneypb/configuration/v1"
7+
currencyv1 "github.com/ft-t/go-money-pb/gen/gomoneypb/currency/v1"
78
usersv1 "github.com/ft-t/go-money-pb/gen/gomoneypb/users/v1"
9+
"github.com/shopspring/decimal"
810
)
911

1012
type UserSvc interface {
@@ -47,3 +49,33 @@ type ConfigSvc interface {
4749
_ *configurationv1.GetConfigurationRequest,
4850
) (*configurationv1.GetConfigurationResponse, error)
4951
}
52+
53+
type CurrencySvc interface {
54+
GetCurrencies(
55+
ctx context.Context,
56+
_ *currencyv1.GetCurrenciesRequest,
57+
) (*currencyv1.GetCurrenciesResponse, error)
58+
59+
CreateCurrency(
60+
ctx context.Context,
61+
req *currencyv1.CreateCurrencyRequest,
62+
) (*currencyv1.CreateCurrencyResponse, error)
63+
64+
UpdateCurrency(
65+
ctx context.Context,
66+
req *currencyv1.UpdateCurrencyRequest,
67+
) (*currencyv1.UpdateCurrencyResponse, error)
68+
}
69+
70+
type DecimalSvc interface {
71+
ToString(ctx context.Context, amount decimal.Decimal, currency string) string
72+
}
73+
74+
type ConverterSvc interface {
75+
Convert(
76+
ctx context.Context,
77+
fromCurrency string,
78+
toCurrency string,
79+
amount decimal.Decimal,
80+
) (decimal.Decimal, error)
81+
}

cmd/server/main.go

+5
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ func main() {
5959

6060
decimalSvc := currency.NewDecimalService()
6161

62+
_, err = NewCurrencyApi(grpcServer, currency.NewService(), currency.NewConverter(), decimalSvc)
63+
if err != nil {
64+
log.Logger.Fatal().Err(err).Msg("failed to create config handler")
65+
}
66+
6267
mapper := mappers.NewMapper(&mappers.MapperConfig{
6368
DecimalSvc: decimalSvc,
6469
})

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ require (
77
connectrpc.com/grpcreflect v1.3.0
88
github.com/cockroachdb/errors v1.11.3
99
github.com/dgrijalva/jwt-go v3.2.0+incompatible
10-
github.com/ft-t/go-money-pb v0.0.0-20250225123647-d3dc09777fc4
10+
github.com/ft-t/go-money-pb v0.0.0-20250225190317-01b85e9aeaa8
1111
github.com/go-gormigrate/gormigrate/v2 v2.1.3
1212
github.com/golang-jwt/jwt/v5 v5.2.1
1313
github.com/golang/mock v1.6.0

go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
1515
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1616
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
1717
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
18-
github.com/ft-t/go-money-pb v0.0.0-20250225123647-d3dc09777fc4 h1:lXPFhyf3BhGEOFywOYyNEhQoEJf063IfvtKoRdeUOjs=
19-
github.com/ft-t/go-money-pb v0.0.0-20250225123647-d3dc09777fc4/go.mod h1:PI8ulxDJ0HamtFdQhvfOTJDla0blE6tJX9EuMw1IrzU=
18+
github.com/ft-t/go-money-pb v0.0.0-20250225190317-01b85e9aeaa8 h1:5PI1tx4EYjgZKWCHhdQbvEJXPg3PKKN2BI4ByVzBVMA=
19+
github.com/ft-t/go-money-pb v0.0.0-20250225190317-01b85e9aeaa8/go.mod h1:PI8ulxDJ0HamtFdQhvfOTJDla0blE6tJX9EuMw1IrzU=
2020
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
2121
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
2222
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

pkg/appcfg/interfaces.go

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package appcfg
22

33
import "context"
44

5+
//go:generate mockgen -destination interfaces_mocks_test.go -package appcfg_test -source=interfaces.go
6+
57
type UserSvc interface {
68
ShouldCreateAdmin(ctx context.Context) (bool, error)
79
}

pkg/appcfg/service.go

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package appcfg
33
import (
44
"context"
55
configurationv1 "github.com/ft-t/go-money-pb/gen/gomoneypb/configuration/v1"
6+
"github.com/ft-t/go-money/pkg/configuration"
67
)
78

89
type Service struct {
@@ -32,5 +33,6 @@ func (s *Service) GetConfiguration(
3233

3334
return &configurationv1.GetConfigurationResponse{
3435
ShouldCreateAdmin: shouldCreatedAdmin,
36+
BaseCurrency: configuration.BaseCurrency,
3537
}, nil
3638
}

pkg/appcfg/service_test.go

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package appcfg_test
2+
3+
import (
4+
"context"
5+
"github.com/cockroachdb/errors"
6+
configurationv1 "github.com/ft-t/go-money-pb/gen/gomoneypb/configuration/v1"
7+
"github.com/ft-t/go-money/pkg/appcfg"
8+
"github.com/ft-t/go-money/pkg/configuration"
9+
"github.com/golang/mock/gomock"
10+
"github.com/stretchr/testify/assert"
11+
"testing"
12+
)
13+
14+
func TestGetConfiguration(t *testing.T) {
15+
t.Run("success", func(t *testing.T) {
16+
userSvc := NewMockUserSvc(gomock.NewController(t))
17+
userSvc.EXPECT().ShouldCreateAdmin(gomock.Any()).
18+
Return(true, nil)
19+
20+
srv := appcfg.NewService(&appcfg.ServiceConfig{
21+
UserSvc: userSvc,
22+
})
23+
24+
resp, err := srv.GetConfiguration(context.TODO(), &configurationv1.GetConfigurationRequest{})
25+
assert.NoError(t, err)
26+
assert.True(t, resp.ShouldCreateAdmin)
27+
assert.Equal(t, "USD", resp.BaseCurrency)
28+
assert.Equal(t, configuration.BaseCurrency, resp.BaseCurrency)
29+
})
30+
31+
t.Run("fail", func(t *testing.T) {
32+
userSvc := NewMockUserSvc(gomock.NewController(t))
33+
userSvc.EXPECT().ShouldCreateAdmin(gomock.Any()).
34+
Return(false, errors.New("some error"))
35+
36+
srv := appcfg.NewService(&appcfg.ServiceConfig{
37+
UserSvc: userSvc,
38+
})
39+
40+
resp, err := srv.GetConfiguration(context.TODO(), &configurationv1.GetConfigurationRequest{})
41+
assert.ErrorContains(t, err, "some error")
42+
assert.Nil(t, resp)
43+
})
44+
}

pkg/configuration/const.go

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
package configuration
22

3+
import "time"
4+
35
const (
4-
BaseCurrency = "EUR"
6+
BaseCurrency = "USD"
7+
8+
DefaultCacheTTL = 1 * time.Minute
9+
10+
DefaultDecimalPlaces = 2
511
)

pkg/currency/converter.go

+2-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"github.com/hashicorp/golang-lru/v2/expirable"
99
"github.com/samber/lo"
1010
"github.com/shopspring/decimal"
11-
"time"
1211
)
1312

1413
type Converter struct {
@@ -17,7 +16,7 @@ type Converter struct {
1716

1817
func NewConverter() *Converter {
1918
return &Converter{
20-
cache: expirable.NewLRU[string, decimal.Decimal](100, nil, 30*time.Second),
19+
cache: expirable.NewLRU[string, decimal.Decimal](100, nil, configuration.DefaultCacheTTL),
2120
}
2221
}
2322

@@ -74,7 +73,7 @@ func (c *Converter) fetchRates(
7473

7574
db := database.FromContext(ctx, database.GetDbWithContext(ctx, database.DbTypeReadonly))
7675

77-
var rates []*database.ExchangeRate
76+
var rates []*database.Currency
7877
if err := db.Where("id IN ?", missing).Find(&rates).Error; err != nil {
7978
return resp, nil
8079
}

pkg/currency/decimals.go

+25-9
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,40 @@ package currency
22

33
import (
44
"context"
5+
"github.com/ft-t/go-money/pkg/configuration"
6+
"github.com/ft-t/go-money/pkg/database"
7+
"github.com/hashicorp/golang-lru/v2/expirable"
58
"github.com/shopspring/decimal"
69
)
710

811
type DecimalService struct {
12+
decimalCountCache *expirable.LRU[string, int32]
913
}
1014

1115
func NewDecimalService() *DecimalService {
12-
return &DecimalService{}
16+
return &DecimalService{
17+
decimalCountCache: expirable.NewLRU[string, int32](100, nil, configuration.DefaultCacheTTL),
18+
}
1319
}
1420

15-
func (s *DecimalService) GetCurrencyDecimals(currency string) int32 {
16-
return 2
21+
func (s *DecimalService) GetCurrencyDecimals(ctx context.Context, currency string) int32 {
22+
if cached, ok := s.decimalCountCache.Get(currency); ok {
23+
return cached
24+
}
25+
26+
db := database.GetDbWithContext(ctx, database.DbTypeReadonly)
27+
28+
var currencyDecimal int32
29+
if err := db.Model(&database.Currency{}).Where("id = ?", currency).
30+
Select("decimal_places").Find(&currencyDecimal).Error; err != nil {
31+
return configuration.DefaultDecimalPlaces
32+
}
33+
34+
s.decimalCountCache.Add(currency, currencyDecimal)
35+
36+
return currencyDecimal
1737
}
1838

19-
func (s *DecimalService) ToString(
20-
ctx context.Context,
21-
amount decimal.Decimal,
22-
currency string,
23-
) string {
24-
return amount.StringFixed(s.GetCurrencyDecimals(currency))
39+
func (s *DecimalService) ToString(ctx context.Context, amount decimal.Decimal, currency string) string {
40+
return amount.StringFixed(s.GetCurrencyDecimals(ctx, currency))
2541
}

pkg/currency/decimals_test.go

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package currency_test
2+
3+
import (
4+
"context"
5+
"github.com/ft-t/go-money/pkg/configuration"
6+
"github.com/ft-t/go-money/pkg/currency"
7+
"github.com/ft-t/go-money/pkg/database"
8+
"github.com/ft-t/go-money/pkg/testingutils"
9+
"github.com/shopspring/decimal"
10+
"github.com/stretchr/testify/assert"
11+
"gorm.io/gorm"
12+
"os"
13+
"testing"
14+
)
15+
16+
var gormDB *gorm.DB
17+
var cfg *configuration.Configuration
18+
19+
func TestMain(m *testing.M) {
20+
cfg = configuration.GetConfiguration()
21+
gormDB = database.GetDb(database.DbTypeMaster)
22+
23+
os.Exit(m.Run())
24+
}
25+
26+
func TestToString(t *testing.T) {
27+
t.Run("success", func(t *testing.T) {
28+
assert.NoError(t, testingutils.FlushAllTables(cfg.Db))
29+
30+
ct := &database.Currency{
31+
ID: "USD",
32+
DecimalPlaces: 3,
33+
}
34+
assert.NoError(t, gormDB.Create(ct).Error)
35+
36+
dec := currency.NewDecimalService()
37+
38+
res := dec.ToString(context.TODO(), decimal.RequireFromString("200"), "USD")
39+
assert.EqualValues(t, "200.000", res)
40+
41+
res = dec.ToString(context.TODO(), decimal.RequireFromString("200"), "USD") // from cache
42+
assert.EqualValues(t, "200.000", res)
43+
})
44+
}

0 commit comments

Comments
 (0)