diff --git a/api/v3/handlers/currencies/list.go b/api/v3/handlers/currencies/list.go index 7472fef0dd..7ce2b93131 100644 --- a/api/v3/handlers/currencies/list.go +++ b/api/v3/handlers/currencies/list.go @@ -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) diff --git a/api/v3/handlers/currencies/list_test.go b/api/v3/handlers/currencies/list_test.go new file mode 100644 index 0000000000..06eab50449 --- /dev/null +++ b/api/v3/handlers/currencies/list_test.go @@ -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) + }) + } +} diff --git a/app/common/currency.go b/app/common/currency.go index e5d0ea2184..57c5501f22 100644 --- a/app/common/currency.go +++ b/app/common/currency.go @@ -7,6 +7,7 @@ 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" ) @@ -14,8 +15,11 @@ import ( 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, diff --git a/openmeter/currencies/adapter/currencies.go b/openmeter/currencies/adapter/currencies.go index 4fc2414560..0ca7851123 100644 --- a/openmeter/currencies/adapter/currencies.go +++ b/openmeter/currencies/adapter/currencies.go @@ -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" @@ -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() { diff --git a/openmeter/currencies/adapter/currencies_test.go b/openmeter/currencies/adapter/currencies_test.go new file mode 100644 index 0000000000..4eaaffda61 --- /dev/null +++ b/openmeter/currencies/adapter/currencies_test.go @@ -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) + }) + } +} diff --git a/openmeter/currencies/currencyresolver/resolver.go b/openmeter/currencies/currencyresolver/resolver.go new file mode 100644 index 0000000000..9272fde6b8 --- /dev/null +++ b/openmeter/currencies/currencyresolver/resolver.go @@ -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 +} diff --git a/openmeter/currencies/currencyresolver/resolver_test.go b/openmeter/currencies/currencyresolver/resolver_test.go new file mode 100644 index 0000000000..0954f12ada --- /dev/null +++ b/openmeter/currencies/currencyresolver/resolver_test.go @@ -0,0 +1,219 @@ +package currencyresolver_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/openmeter/currencies/currencyresolver" + currenciestestutils "github.com/openmeterio/openmeter/openmeter/currencies/testutils" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestNew(t *testing.T) { + _, err := currencyresolver.New(nil) + require.EqualError(t, err, "currency service is required") +} + +func TestCurrencyResolver(t *testing.T) { + env := currenciestestutils.NewTestEnv(t) + t.Cleanup(func() { + env.Close(t) + }) + + namespace := currenciestestutils.NewTestNamespace(t) + otherNamespace := currenciestestutils.NewTestNamespace(t) + + credits, err := env.Service.CreateCurrency(t.Context(), currencies.CreateCurrencyInput{ + Namespace: namespace, + CurrencyDetails: currencyx.CurrencyDetails{ + Code: "CREDITS", + Name: "Credits", + Symbol: "C", + Precision: 2, + DecimalMark: ".", + ThousandsSeparator: ",", + }, + }) + require.NoError(t, err) + + points, err := env.Service.CreateCurrency(t.Context(), currencies.CreateCurrencyInput{ + Namespace: namespace, + CurrencyDetails: currencyx.CurrencyDetails{ + Code: "POINTS", + Name: "Points", + Symbol: "P", + Precision: 4, + DecimalMark: ".", + ThousandsSeparator: ",", + }, + }) + require.NoError(t, err) + + otherCredits, err := env.Service.CreateCurrency(t.Context(), currencies.CreateCurrencyInput{ + Namespace: otherNamespace, + CurrencyDetails: currencyx.CurrencyDetails{ + Code: "CREDITS", + Name: "Other Credits", + Symbol: "OC", + Precision: 6, + DecimalMark: ".", + ThousandsSeparator: ",", + }, + }) + require.NoError(t, err) + + resolver, err := currencyresolver.New(env.Service) + require.NoError(t, err) + + namespacedResolver := resolver.WithNamespace(namespace) + assert.Equal(t, namespace, namespacedResolver.Namespace()) + + t.Run("ResolveCurrency", func(t *testing.T) { + tests := []struct { + name string + ref currencies.CurrencyRef + expected *currencies.Currency + expectedType currencyx.CurrencyType + expectedError string + validationErr bool + notFound bool + }{ + { + name: "empty reference", + ref: currencies.CurrencyRef{}, + expectedError: "validation error: currency id or code is required", + validationErr: true, + }, + { + name: "custom currency by id", + ref: currencies.CurrencyRef{ + ID: credits.ID, + }, + expected: &credits, + expectedType: currencyx.CurrencyTypeCustom, + }, + { + name: "custom currency by code", + ref: currencies.CurrencyRef{ + Code: credits.Details().Code, + }, + expected: &credits, + expectedType: currencyx.CurrencyTypeCustom, + }, + { + name: "fiat currency by code", + ref: currencies.CurrencyRef{ + Code: "USD", + }, + expectedType: currencyx.CurrencyTypeFiat, + }, + { + name: "id takes precedence over code", + ref: currencies.CurrencyRef{ + ID: credits.ID, + Code: points.Details().Code, + }, + expected: &credits, + expectedType: currencyx.CurrencyTypeCustom, + }, + { + name: "missing id does not fall back to code", + ref: currencies.CurrencyRef{ + ID: "missing", + Code: credits.Details().Code, + }, + notFound: true, + }, + { + name: "missing code", + ref: currencies.CurrencyRef{ + Code: "UNKNOWN", + }, + notFound: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := namespacedResolver.ResolveCurrency(t.Context(), test.ref) + switch { + case test.expectedError != "": + require.EqualError(t, err, test.expectedError) + assert.Equal(t, test.validationErr, models.IsGenericValidationError(err)) + assert.Nil(t, result) + case test.notFound: + require.Error(t, err) + assert.True(t, models.IsGenericNotFoundError(err)) + assert.Nil(t, result) + default: + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, test.expectedType, result.Type()) + + if test.ref.ID == "" { + assert.Equal(t, test.ref.Code, result.Details().Code) + } + + if test.expected != nil { + assert.Equal(t, test.expected.ID, result.ID) + assert.Equal(t, test.expected.Namespace, result.Namespace) + assert.NotEqual(t, otherCredits.ID, result.ID) + } + } + }) + } + }) + + t.Run("BatchResolveCurrencies", func(t *testing.T) { + creditsByID := currencies.CurrencyRef{ID: credits.ID} + creditsByCode := currencies.CurrencyRef{Code: credits.Details().Code} + pointsByIDWithDifferentCode := currencies.CurrencyRef{ + ID: points.ID, + Code: credits.Details().Code, + } + usdByCode := currencies.CurrencyRef{Code: "USD"} + missingByCode := currencies.CurrencyRef{Code: "UNKNOWN"} + + result, err := namespacedResolver.BatchResolveCurrencies( + t.Context(), + creditsByID, + creditsByCode, + pointsByIDWithDifferentCode, + usdByCode, + missingByCode, + ) + require.NoError(t, err) + require.Len(t, result, 5) + + require.NotNil(t, result[creditsByID]) + assert.Equal(t, credits.ID, result[creditsByID].ID) + + require.NotNil(t, result[creditsByCode]) + assert.Equal(t, credits.ID, result[creditsByCode].ID) + + require.NotNil(t, result[pointsByIDWithDifferentCode]) + assert.Equal(t, points.ID, result[pointsByIDWithDifferentCode].ID) + + require.NotNil(t, result[usdByCode]) + assert.Equal(t, currencyx.CurrencyTypeFiat, result[usdByCode].Type()) + assert.Equal(t, currencyx.Code("USD"), result[usdByCode].Details().Code) + + assert.Nil(t, result[missingByCode]) + }) + + t.Run("BatchResolveCurrenciesEmpty", func(t *testing.T) { + result, err := namespacedResolver.BatchResolveCurrencies(t.Context()) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("NamespaceIsRequired", func(t *testing.T) { + result, err := resolver.BatchResolveCurrencies(t.Context(), "", currencies.CurrencyRef{ID: credits.ID}) + require.EqualError(t, err, "namespace is not set") + assert.Nil(t, result) + }) +} diff --git a/openmeter/currencies/resolver.go b/openmeter/currencies/resolver.go new file mode 100644 index 0000000000..797e3b583f --- /dev/null +++ b/openmeter/currencies/resolver.go @@ -0,0 +1,42 @@ +package currencies + +import ( + "context" + "errors" + + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ models.Validator = (*CurrencyRef)(nil) + +// CurrencyRef identifies a currency by its managed resource ID or code. +// ID takes precedence when both fields are set. +type CurrencyRef struct { + ID string `json:"id,omitempty"` + Code currencyx.Code `json:"code,omitempty"` +} + +func (r CurrencyRef) Validate() error { + if r.ID != "" { + return nil + } + + if r.Code == "" { + return models.NewNillableGenericValidationError(errors.New("currency id or code is required")) + } + + return nil +} + +type CurrencyResolver interface { + ResolveCurrency(ctx context.Context, namespace string, ref CurrencyRef) (*Currency, error) + BatchResolveCurrencies(ctx context.Context, namespace string, refs ...CurrencyRef) (map[CurrencyRef]*Currency, error) + WithNamespace(namespace string) NamespacedCurrencyResolver +} + +type NamespacedCurrencyResolver interface { + ResolveCurrency(ctx context.Context, ref CurrencyRef) (*Currency, error) + BatchResolveCurrencies(ctx context.Context, refs ...CurrencyRef) (map[CurrencyRef]*Currency, error) + Namespace() string +} diff --git a/openmeter/currencies/service.go b/openmeter/currencies/service.go index 1e2f743be7..96a71229dc 100644 --- a/openmeter/currencies/service.go +++ b/openmeter/currencies/service.go @@ -50,13 +50,24 @@ func (o OrderBy) Validate() error { var _ models.Validator = (*ListCurrenciesInput)(nil) +// FilteringOptions controls how sibling currency filters are combined. +// The default behavior intersects filters; Union combines them with OR. +// This option is internal to the currencies service until cross-field filter +// composition is supported by pkg/filter. +type FilteringOptions struct { + Union bool `json:"-"` +} + type ListCurrenciesInput struct { pagination.Page + FilteringOptions Namespace string `json:"namespace"` - // FilterType filters currencies by type: "custom" or "fiat". Nil means no filter. - FilterType *CurrencyType `json:"filter_type,omitempty"` + // CurrencyType filters currencies by type: "custom" or "fiat". Nil means no filter. + CurrencyType *CurrencyType `json:"currency_type,omitempty"` + // ID filters currencies by managed resource ID. Fiat currencies have no ID. + ID *filter.FilterString `json:"id,omitempty"` // Code filters currencies by code field. Nil means no filter. Code *filter.FilterString `json:"code,omitempty"` @@ -71,9 +82,15 @@ func (i ListCurrenciesInput) Validate() error { errs = append(errs, errors.New("namespace is required")) } - if i.FilterType != nil { - if err := i.FilterType.Validate(); err != nil { - errs = append(errs, fmt.Errorf("filter_type: %w", err)) + if i.CurrencyType != nil { + if err := i.CurrencyType.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency_type: %w", err)) + } + } + + if i.ID != nil { + if err := i.ID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("id: %w", err)) } } diff --git a/openmeter/currencies/service/service.go b/openmeter/currencies/service/service.go index 15d423cf52..8679fd4dd6 100644 --- a/openmeter/currencies/service/service.go +++ b/openmeter/currencies/service/service.go @@ -41,8 +41,8 @@ func (s *service) ListCurrencies(ctx context.Context, params currencies.ListCurr } return transaction.Run(ctx, s.adapter, func(ctx context.Context) (pagination.Result[currencies.Currency], error) { - includeCustom := params.FilterType == nil || *params.FilterType == currencies.CurrencyTypeCustom - includeFiat := params.FilterType == nil || *params.FilterType == currencies.CurrencyTypeFiat + includeCustom := params.CurrencyType == nil || *params.CurrencyType == currencies.CurrencyTypeCustom + includeFiat := params.CurrencyType == nil || *params.CurrencyType == currencies.CurrencyTypeFiat // Custom-only: delegate pagination entirely to the adapter (DB-level) if includeCustom && !includeFiat { @@ -64,17 +64,18 @@ func (s *service) ListCurrencies(ctx context.Context, params currencies.ListCurr } if includeFiat { - matchCode := params.Code.LoFilterPredicate() filteredMatchCode, err := lo.FilterErr(currency.Definitions(), func(def *currency.Def, _ int) (bool, error) { // NOTE: this filters out non-iso currencies such as crypto if def.ISONumeric == "" { return false, nil } - return matchCode(def.ISOCode.String(), 0) + + return matchesCurrencyFilters(params, "", def.ISOCode.String()) }) if err != nil { return pagination.Result[currencies.Currency]{}, fmt.Errorf("filtering fiat currencies by code: %w", err) } + for _, def := range filteredMatchCode { curr, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). WithCode(currencyx.Code(def.ISOCode)). @@ -133,6 +134,39 @@ func (s *service) ListCurrencies(ctx context.Context, params currencies.ListCurr }) } +func matchesCurrencyFilters(params currencies.ListCurrenciesInput, id, code string) (bool, error) { + hasIDFilter := params.ID != nil && !params.ID.IsEmpty() + hasCodeFilter := params.Code != nil && !params.Code.IsEmpty() + + if !hasIDFilter && !hasCodeFilter { + return true, nil + } + + idMatches := false + if hasIDFilter { + var err error + idMatches, err = params.ID.Match(id) + if err != nil { + return false, fmt.Errorf("matching currency id: %w", err) + } + } + + codeMatches := false + if hasCodeFilter { + var err error + codeMatches, err = params.Code.Match(code) + if err != nil { + return false, fmt.Errorf("matching currency code: %w", err) + } + } + + if params.Union { + return idMatches || codeMatches, nil + } + + return (!hasIDFilter || idMatches) && (!hasCodeFilter || codeMatches), nil +} + func (s *service) CreateCurrency(ctx context.Context, params currencies.CreateCurrencyInput) (currencies.Currency, error) { if err := params.Validate(); err != nil { return currencies.Currency{}, models.NewGenericValidationError(fmt.Errorf("invalid input parameters: %w", err)) diff --git a/openmeter/currencies/service/service_test.go b/openmeter/currencies/service/service_test.go index e5056f3c37..919ff7c948 100644 --- a/openmeter/currencies/service/service_test.go +++ b/openmeter/currencies/service/service_test.go @@ -320,9 +320,9 @@ func TestCurrenciesService(t *testing.T) { t.Run("List", func(t *testing.T) { // given: - // - an independently persisted custom currency + // - independently persisted custom currencies listNamespace := currenciestestutils.NewTestNamespace(t) - _, err := env.Service.CreateCurrency(t.Context(), currencies.CreateCurrencyInput{ + points, err := env.Service.CreateCurrency(t.Context(), currencies.CreateCurrencyInput{ Namespace: listNamespace, CurrencyDetails: currencyx.CurrencyDetails{ Code: "POINTS", @@ -335,6 +335,19 @@ func TestCurrenciesService(t *testing.T) { }) require.NoError(t, err) + _, err = env.Service.CreateCurrency(t.Context(), currencies.CreateCurrencyInput{ + Namespace: listNamespace, + CurrencyDetails: currencyx.CurrencyDetails{ + Code: "TOKENS", + Name: "Tokens", + Symbol: "T", + Precision: 4, + DecimalMark: ".", + ThousandsSeparator: ",", + }, + }) + require.NoError(t, err) + // when: // - the service lists the custom currency together with selected fiat currencies result, err := env.Service.ListCurrencies(t.Context(), currencies.ListCurrenciesInput{ @@ -354,6 +367,103 @@ func TestCurrenciesService(t *testing.T) { return item.Details().Code }) assert.ElementsMatch(t, []currencyx.Code{"POINTS", "USD", "EUR"}, codes) + + t.Run("FilterCombination", func(t *testing.T) { + // given: + // - ID and code filters that identify different currencies + testCases := []struct { + name string + filteringOptions currencies.FilteringOptions + currencyType *currencies.CurrencyType + id *filter.FilterString + code *filter.FilterString + expectedCodes []currencyx.Code + }{ + { + name: "filter by id", + id: &filter.FilterString{ + In: lo.ToPtr([]string{points.ID}), + }, + expectedCodes: []currencyx.Code{"POINTS"}, + }, + { + name: "filter by custom currency type", + currencyType: lo.ToPtr(currencies.CurrencyTypeCustom), + code: &filter.FilterString{ + In: lo.ToPtr([]string{"POINTS", "USD"}), + }, + expectedCodes: []currencyx.Code{"POINTS"}, + }, + { + name: "filter by fiat currency type", + currencyType: lo.ToPtr(currencies.CurrencyTypeFiat), + code: &filter.FilterString{ + In: lo.ToPtr([]string{"POINTS", "USD"}), + }, + expectedCodes: []currencyx.Code{"USD"}, + }, + { + name: "intersection", + id: &filter.FilterString{ + In: lo.ToPtr([]string{points.ID}), + }, + code: &filter.FilterString{ + In: lo.ToPtr([]string{"TOKENS"}), + }, + expectedCodes: nil, + }, + { + name: "union custom currencies", + filteringOptions: currencies.FilteringOptions{ + Union: true, + }, + currencyType: lo.ToPtr(currencies.CurrencyTypeCustom), + id: &filter.FilterString{ + In: lo.ToPtr([]string{points.ID}), + }, + code: &filter.FilterString{ + In: lo.ToPtr([]string{"TOKENS"}), + }, + expectedCodes: []currencyx.Code{"POINTS", "TOKENS"}, + }, + { + name: "union custom and fiat currencies", + filteringOptions: currencies.FilteringOptions{ + Union: true, + }, + id: &filter.FilterString{ + In: lo.ToPtr([]string{points.ID}), + }, + code: &filter.FilterString{ + In: lo.ToPtr([]string{"USD"}), + }, + expectedCodes: []currencyx.Code{"POINTS", "USD"}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // when: + // - currencies are listed with the selected filter combination mode + result, err := env.Service.ListCurrencies(t.Context(), currencies.ListCurrenciesInput{ + Page: pagination.NewPage(1, 10), + FilteringOptions: testCase.filteringOptions, + Namespace: listNamespace, + CurrencyType: testCase.currencyType, + ID: testCase.id, + Code: testCase.code, + }) + + // then: + // - sibling filters are intersected by default or unioned when requested + 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) + }) + } + }) }) }) }