Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/v3/handlers/currencies/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func (h *handler) ListCurrencies() ListCurrenciesHandler {
if params.Filter != nil {
if params.Filter.Type != nil {
ft := FromAPIBillingCurrencyType(*params.Filter.Type)
req.FilterType = &ft
req.CurrencyType = &ft
}

code, err := filters.FromAPIFilterString(params.Filter.Code)
Expand Down
76 changes: 76 additions & 0 deletions api/v3/handlers/currencies/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package currencies

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

v3 "github.com/openmeterio/openmeter/api/v3"
"github.com/openmeterio/openmeter/openmeter/currencies"
"github.com/openmeterio/openmeter/pkg/pagination"
)

type listCurrenciesService struct {
currencies.Service

input currencies.ListCurrenciesInput
}

func (s *listCurrenciesService) ListCurrencies(_ context.Context, input currencies.ListCurrenciesInput) (pagination.Result[currencies.Currency], error) {
s.input = input

return pagination.Result[currencies.Currency]{
Page: input.Page,
}, nil
}

func TestListCurrenciesFilterByType(t *testing.T) {
testCases := []struct {
name string
apiType v3.BillingCurrencyType
currencyType currencies.CurrencyType
}{
{
name: "custom",
apiType: v3.BillingCurrencyTypeCustom,
currencyType: currencies.CurrencyTypeCustom,
},
{
name: "fiat",
apiType: v3.BillingCurrencyTypeFiat,
currencyType: currencies.CurrencyTypeFiat,
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
// given:
// - a list request filtered by one API currency type
service := &listCurrenciesService{}
handler := New(func(context.Context) (string, error) {
return "test", nil
}, service)

request := httptest.NewRequest(http.MethodGet, "/api/v3/currencies", nil)
response := httptest.NewRecorder()

// when:
// - the v3 list handler decodes the generated API parameters
handler.ListCurrencies().With(v3.ListCurrenciesParams{
Filter: &v3.ListCurrenciesParamsFilter{
Type: &testCase.apiType,
},
}).ServeHTTP(response, request)

// then:
// - the service receives the corresponding domain currency type filter
require.Equal(t, http.StatusOK, response.Code)
require.NotNil(t, service.input.CurrencyType)
assert.Equal(t, testCase.currencyType, *service.input.CurrencyType)
})
}
}
4 changes: 4 additions & 0 deletions app/common/currency.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ import (

"github.com/openmeterio/openmeter/openmeter/currencies"
currencyadapter "github.com/openmeterio/openmeter/openmeter/currencies/adapter"
"github.com/openmeterio/openmeter/openmeter/currencies/currencyresolver"
"github.com/openmeterio/openmeter/openmeter/currencies/service"
entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
)

var Currency = wire.NewSet(
NewCurrencyAdapter,
NewCurrencyService,
NewCurrencyResolver,
)

var NewCurrencyResolver = currencyresolver.New

func NewCurrencyAdapter(db *entdb.Client) (currencies.Repository, error) {
repo, err := currencyadapter.New(currencyadapter.Config{
Client: db,
Expand Down
31 changes: 30 additions & 1 deletion openmeter/currencies/adapter/currencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
"github.com/openmeterio/openmeter/openmeter/ent/db/currencycostbasis"
"github.com/openmeterio/openmeter/openmeter/ent/db/customcurrency"
"github.com/openmeterio/openmeter/openmeter/ent/db/predicate"
"github.com/openmeterio/openmeter/pkg/clock"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/filter"
Expand Down Expand Up @@ -93,10 +94,38 @@ func mapCostBasisFromDB(c *entdb.CurrencyCostBasis) currencies.CostBasis {

func (a *adapter) ListCustomCurrencies(ctx context.Context, params currencies.ListCurrenciesInput) (pagination.Result[currencies.Currency], error) {
return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (pagination.Result[currencies.Currency], error) {
if params.CurrencyType != nil && *params.CurrencyType != currencies.CurrencyTypeCustom {
return pagination.Result[currencies.Currency]{
Page: params.Page,
Items: []currencies.Currency{},
}, nil
}

q := tx.db.CustomCurrency.Query().
Where(customcurrency.Namespace(params.Namespace))

q = filter.ApplyToQuery(q, params.Code, customcurrency.FieldCode)
if params.Union {
predicates := make([]predicate.CustomCurrency, 0, 2)

if params.ID != nil {
if idPredicate := filter.SelectPredicate[predicate.CustomCurrency](params.ID, customcurrency.FieldID); idPredicate != nil {
predicates = append(predicates, *idPredicate)
}
}

if params.Code != nil {
if codePredicate := filter.SelectPredicate[predicate.CustomCurrency](params.Code, customcurrency.FieldCode); codePredicate != nil {
predicates = append(predicates, *codePredicate)
}
}

if len(predicates) > 0 {
q = q.Where(customcurrency.Or(predicates...))
}
} else {
q = filter.ApplyToQuery(q, params.ID, customcurrency.FieldID)
q = filter.ApplyToQuery(q, params.Code, customcurrency.FieldCode)
}

order := entutils.GetOrdering(sortx.OrderDefault)
if !params.Order.IsDefaultValue() {
Expand Down
75 changes: 75 additions & 0 deletions openmeter/currencies/adapter/currencies_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package adapter_test

import (
"testing"

"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/openmeterio/openmeter/openmeter/currencies"
currenciestestutils "github.com/openmeterio/openmeter/openmeter/currencies/testutils"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/pagination"
)

func TestListCustomCurrenciesFiltersCurrencyType(t *testing.T) {
env := currenciestestutils.NewTestEnv(t)
t.Cleanup(func() {
env.Close(t)
})

namespace := currenciestestutils.NewTestNamespace(t)
created, err := env.Service.CreateCurrency(t.Context(), currencies.CreateCurrencyInput{
Namespace: namespace,
CurrencyDetails: currencyx.CurrencyDetails{
Code: "TOKENS",
Name: "Tokens",
Symbol: "T",
Precision: 2,
DecimalMark: ".",
ThousandsSeparator: ",",
},
})
require.NoError(t, err)

testCases := []struct {
name string
currencyType currencies.CurrencyType
expectedCodes []currencyx.Code
}{
{
name: "custom",
currencyType: currencies.CurrencyTypeCustom,
expectedCodes: []currencyx.Code{created.Details().Code},
},
{
name: "fiat",
currencyType: currencies.CurrencyTypeFiat,
expectedCodes: nil,
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
// given:
// - a persisted custom currency and a requested currency type
// when:
// - custom currencies are listed directly through the repository
result, err := env.Repository.ListCustomCurrencies(t.Context(), currencies.ListCurrenciesInput{
Page: pagination.NewPage(1, 10),
Namespace: namespace,
CurrencyType: &testCase.currencyType,
})

// then:
// - the adapter returns custom records only for the custom type
require.NoError(t, err)
actualCodes := lo.Map(result.Items, func(item currencies.Currency, _ int) currencyx.Code {
return item.Details().Code
})
assert.ElementsMatch(t, testCase.expectedCodes, actualCodes)
assert.Equal(t, len(testCase.expectedCodes), result.TotalCount)
})
}
}
160 changes: 160 additions & 0 deletions openmeter/currencies/currencyresolver/resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package currencyresolver

import (
"context"
"errors"
"fmt"

"github.com/samber/lo"

"github.com/openmeterio/openmeter/openmeter/currencies"
"github.com/openmeterio/openmeter/pkg/filter"
"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/pagination"
)

func New(service currencies.Service) (currencies.CurrencyResolver, error) {
if service == nil {
return nil, errors.New("currency service is required")
}

return &resolver{
service: service,
}, nil
}

var _ currencies.NamespacedCurrencyResolver = (*namespacedResolver)(nil)

type namespacedResolver struct {
resolver *resolver
namespace string
}

func (n *namespacedResolver) Namespace() string {
return n.namespace
}

func (n *namespacedResolver) ResolveCurrency(ctx context.Context, ref currencies.CurrencyRef) (*currencies.Currency, error) {
return n.resolver.ResolveCurrency(ctx, n.namespace, ref)
}

func (n *namespacedResolver) BatchResolveCurrencies(ctx context.Context, refs ...currencies.CurrencyRef) (map[currencies.CurrencyRef]*currencies.Currency, error) {
return n.resolver.BatchResolveCurrencies(ctx, n.namespace, refs...)
}

var _ currencies.CurrencyResolver = (*resolver)(nil)

type resolver struct {
service currencies.Service
}

func (r *resolver) WithNamespace(namespace string) currencies.NamespacedCurrencyResolver {
return &namespacedResolver{
resolver: r,
namespace: namespace,
}
}

func (r *resolver) ResolveCurrency(ctx context.Context, namespace string, ref currencies.CurrencyRef) (*currencies.Currency, error) {
if err := ref.Validate(); err != nil {
return nil, err
}

resolved, err := r.BatchResolveCurrencies(ctx, namespace, ref)
if err != nil {
return nil, fmt.Errorf("failed to fetch currency: %w", err)
}

currency := resolved[ref]
if currency != nil {
return currency, nil
}

if ref.ID != "" {
return nil, models.NewGenericNotFoundError(fmt.Errorf("currency [currency.id=%s]", ref.ID))
}

return nil, models.NewGenericNotFoundError(fmt.Errorf("currency [currency.code=%s]", ref.Code))
}

func (r *resolver) BatchResolveCurrencies(ctx context.Context, namespace string, refs ...currencies.CurrencyRef) (map[currencies.CurrencyRef]*currencies.Currency, error) {
if namespace == "" {
return nil, errors.New("namespace is not set")
}

if len(refs) == 0 {
return nil, nil
}

ids := make([]string, 0, len(refs))
codes := make([]string, 0, len(refs))
for idx, ref := range refs {
if err := ref.Validate(); err != nil {
return nil, fmt.Errorf("invalid currency reference at index %d: %w", idx, err)
}

if ref.ID != "" {
ids = append(ids, ref.ID)
} else {
codes = append(codes, ref.Code.String())
}
}
ids = lo.Uniq(ids)
codes = lo.Uniq(codes)

var idFilter *filter.FilterString
if len(ids) > 0 {
idFilter = &filter.FilterString{In: &ids}
}

var codeFilter *filter.FilterString
if len(codes) > 0 {
codeFilter = &filter.FilterString{In: &codes}
}

items, err := pagination.CollectAll(ctx, pagination.NewPaginator(func(ctx context.Context, page pagination.Page) (pagination.Result[currencies.Currency], error) {
return r.service.ListCurrencies(ctx, currencies.ListCurrenciesInput{
Page: page,
FilteringOptions: currencies.FilteringOptions{
Union: true,
},
Namespace: namespace,
ID: idFilter,
Code: codeFilter,
})
}), min(len(ids)+len(codes), 100))
if err != nil {
return nil, fmt.Errorf("failed to fetch currencies: %w", err)
}

byID := make(map[string]*currencies.Currency, len(items))
byCode := make(map[string]*currencies.Currency, len(items))

for idx := range items {
currency := &items[idx]

if currency.ID != "" {
byID[currency.ID] = currency
}

if currency.DeletedAt == nil {
code := currency.Details().Code.String()
if _, ok := byCode[code]; ok {
return nil, fmt.Errorf("multiple active currencies found for code %q", code)
}

byCode[code] = currency
}
}

result := make(map[currencies.CurrencyRef]*currencies.Currency, len(refs))
for _, ref := range refs {
if ref.ID != "" {
result[ref] = byID[ref.ID]
} else {
result[ref] = byCode[ref.Code.String()]
}
}

return result, nil
}
Loading
Loading