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
Original file line number Diff line number Diff line change
Expand Up @@ -2415,6 +2415,7 @@ export const listCreditGrantsParamsFilter = z
export const getCreditBalanceParamsFilter = z
.object({
currency: stringFieldFilterExact.optional(),
feature_key: stringFieldFilter.optional(),
})
.describe('Filter options for getting a credit balance.')

Expand Down Expand Up @@ -2734,6 +2735,7 @@ export const listCreditTransactionsParamsFilter = z
.object({
type: creditTransactionType.optional(),
currency: billingCurrencyCode.optional(),
feature_key: stringFieldFilter.optional(),
})
.describe('Filter options for listing credit transactions.')

Expand Down
30 changes: 30 additions & 0 deletions api/spec/packages/aip-client-javascript/src/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,21 @@ export interface ListCreditGrantsParamsFilter {
export interface GetCreditBalanceParamsFilter {
/** Filter credit balance by currency. */
currency?: string | { eq?: string; oeq?: string[]; neq?: string }
/** Filter credit balance by feature key. Omit to return the total portfolio value. Use `exists=false` to return only unrestricted balance. */
feature_key?:
| string
| {
eq?: string
neq?: string
contains?: string
ocontains?: string[]
oeq?: string[]
gt?: string
gte?: string
lt?: string
lte?: string
exists?: boolean
}
}

/** Filter options for listing charges. */
Expand Down Expand Up @@ -1518,6 +1533,21 @@ export interface ListCreditTransactionsParamsFilter {
type?: 'funded' | 'consumed' | 'expired'
/** Filter credit transactions by currency. */
currency?: string
/** Filter credit transactions by feature key. Omit to return all credit transactions. Use `exists=false` to return only unrestricted credit transactions. */
feature_key?:
| string
| {
eq?: string
neq?: string
contains?: string
ocontains?: string[]
oeq?: string[]
gt?: string
gte?: string
lt?: string
lte?: string
exists?: boolean
}
}

/** A credit transaction represents a single credit movement on the customer's balance. Credit transactions are immutable. */
Expand Down
13 changes: 13 additions & 0 deletions api/spec/packages/aip/src/customers/credits/operations.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ model GetCreditBalanceParamsFilter {
* Filter credit balance by currency.
*/
currency?: Common.StringFieldFilterExact;

/**
* Filter credit balance by feature key. Omit to return the total portfolio value.
* Use `exists=false` to return only unrestricted balance.
*/
feature_key?: Common.StringFieldFilter;
}

namespace Credits.Balance {
Expand Down Expand Up @@ -198,6 +204,13 @@ model ListCreditTransactionsParamsFilter {
*/
currency?: Currencies.CurrencyCode;

/**
* Filter credit transactions by feature key. Omit to return all credit
* transactions. Use `exists=false` to return only unrestricted credit
* transactions.
*/
feature_key?: Common.StringFieldFilter;

// TODO: add date filters for created_at and booked_at
}

Expand Down
556 changes: 284 additions & 272 deletions api/v3/api.gen.go

Large diffs are not rendered by default.

25 changes: 23 additions & 2 deletions api/v3/filters/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,17 @@ func parseFilterString(qs url.Values, field string) (FilterString, error) {
}
f.Ocontains = items
case OpExists:
f.Exists = lo.ToPtr(true)
exists, err := parseOptionalBool(field, OpExists, p.value)
if err != nil {
return err
}
f.Exists = &exists
case OpNexists:
f.Exists = lo.ToPtr(false)
nexists, err := parseOptionalBool(field, OpNexists, p.value)
if err != nil {
return err
}
f.Exists = lo.ToPtr(!nexists)
default:
return fieldError(field, p.op, ErrUnsupportedOperator)
}
Expand All @@ -337,6 +345,19 @@ func parseFilterString(qs url.Values, field string) (FilterString, error) {
return f, err
}

func parseOptionalBool(field, op, value string) (bool, error) {
if value == "" {
return true, nil
}

valueBool, err := strconv.ParseBool(value)
if err != nil {
return false, fieldError(field, op, fmt.Errorf("invalid boolean %q", value))
}

return valueBool, nil
}

// parseFilterStringExact extracts a FilterStringExact supporting eq, neq, and oeq.
func parseFilterStringExact(qs url.Values, field string) (FilterStringExact, error) {
var f FilterStringExact
Expand Down
42 changes: 42 additions & 0 deletions api/v3/filters/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,61 @@ func TestParse_FilterString(t *testing.T) {
})

t.Run("exists", func(t *testing.T) {
var f testFilter
require.NoError(t, Parse(url.Values{"filter[field][exists]": {"true"}}, &f))
require.NotNil(t, f.Field)
assert.Equal(t, lo.ToPtr(true), f.Field.Exists)
})

t.Run("exists false", func(t *testing.T) {
var f testFilter
require.NoError(t, Parse(url.Values{"filter[field][exists]": {"false"}}, &f))
require.NotNil(t, f.Field)
assert.Equal(t, lo.ToPtr(false), f.Field.Exists)
})

t.Run("exists without value", func(t *testing.T) {
var f testFilter
require.NoError(t, Parse(url.Values{"filter[field][exists]": {""}}, &f))
require.NotNil(t, f.Field)
assert.Equal(t, lo.ToPtr(true), f.Field.Exists)
})

t.Run("exists invalid boolean", func(t *testing.T) {
var f testFilter
err := Parse(url.Values{"filter[field][exists]": {"anything"}}, &f)
require.Error(t, err)
assert.Contains(t, err.Error(), `invalid boolean "anything"`)
})

t.Run("nexists", func(t *testing.T) {
var f testFilter
require.NoError(t, Parse(url.Values{"filter[field][nexists]": {"true"}}, &f))
require.NotNil(t, f.Field)
assert.Equal(t, lo.ToPtr(false), f.Field.Exists)
})

t.Run("nexists false", func(t *testing.T) {
var f testFilter
require.NoError(t, Parse(url.Values{"filter[field][nexists]": {"false"}}, &f))
require.NotNil(t, f.Field)
assert.Equal(t, lo.ToPtr(true), f.Field.Exists)
})

t.Run("nexists without value", func(t *testing.T) {
var f testFilter
require.NoError(t, Parse(url.Values{"filter[field][nexists]": {""}}, &f))
require.NotNil(t, f.Field)
assert.Equal(t, lo.ToPtr(false), f.Field.Exists)
})

t.Run("nexists invalid boolean", func(t *testing.T) {
var f testFilter
err := Parse(url.Values{"filter[field][nexists]": {"anything"}}, &f)
require.Error(t, err)
assert.Contains(t, err.Error(), `invalid boolean "anything"`)
})

t.Run("bare key existence", func(t *testing.T) {
var f testFilter
require.NoError(t, Parse(url.Values{"filter[field]": {""}}, &f))
Expand Down
82 changes: 82 additions & 0 deletions api/v3/handlers/customers/credits/feature_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package customerscredits

import (
"context"
"errors"
"fmt"

"github.com/samber/mo"

api "github.com/openmeterio/openmeter/api/v3"
"github.com/openmeterio/openmeter/api/v3/apierrors"
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
)

func fromAPICustomerCreditFeatureFilter(f *api.StringFieldFilter) (mo.Option[creditpurchase.FeatureFilters], error) {
if f == nil {
return customerbalance.AllFeatureFilter(), nil
}

if f.Exists != nil {
if !*f.Exists {
return customerbalance.NewUnrestrictedFeatureFilter(), nil
}

return customerbalance.AllFeatureFilter(), errors.New("exists=true operator is not supported")
}

if op := unsupportedCustomerCreditFeatureKeyOperator(f); op != "" {
return customerbalance.AllFeatureFilter(), fmt.Errorf("%s operator is not supported", op)
}

features := make([]string, 0, 1+len(f.Oeq))
if f.Eq != nil {
features = append(features, *f.Eq)
}
features = append(features, f.Oeq...)

if len(features) == 0 {
return customerbalance.AllFeatureFilter(), nil
}

featureFilter := customerbalance.NewFeatureFilter(features)
if err := customerbalance.ValidateFeatureFilter(featureFilter); err != nil {
return customerbalance.AllFeatureFilter(), err
}

return featureFilter, nil
}

func unsupportedCustomerCreditFeatureKeyOperator(f *api.StringFieldFilter) string {
switch {
case f.Neq != nil:
return "neq"
case f.Contains != nil:
return "contains"
case len(f.Ocontains) > 0:
return "ocontains"
case f.Gt != nil:
return "gt"
case f.Gte != nil:
return "gte"
case f.Lt != nil:
return "lt"
case f.Lte != nil:
return "lte"
default:
return ""
}
}

func newFeatureKeyFilterBadRequest(ctx context.Context, err error) error {
return apierrors.NewBadRequestError(
ctx,
errors.New("invalid feature_key filter"),
apierrors.InvalidParameters{{
Field: "filter[feature_key]",
Reason: err.Error(),
Source: apierrors.InvalidParamSourceQuery,
}},
)
}
105 changes: 105 additions & 0 deletions api/v3/handlers/customers/credits/feature_filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package customerscredits

import (
"testing"

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

api "github.com/openmeterio/openmeter/api/v3"
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
)

func TestFromAPICustomerCreditFeatureFilter(t *testing.T) {
tests := []struct {
name string
filter *api.StringFieldFilter
want mo.Option[creditpurchase.FeatureFilters]
wantErr bool
}{
{
name: "omitted filter returns all",
want: customerbalance.AllFeatureFilter(),
},
{
name: "eq returns feature filter",
filter: &api.StringFieldFilter{Eq: lo.ToPtr("feature-a")},
want: customerbalance.NewFeatureFilter([]string{"feature-a"}),
},
{
name: "single oeq returns feature filter",
filter: &api.StringFieldFilter{Oeq: []string{"feature-a"}},
want: customerbalance.NewFeatureFilter([]string{"feature-a"}),
},
{
name: "multiple features are rejected",
filter: &api.StringFieldFilter{Oeq: []string{"feature-a", "feature-b"}},
wantErr: true,
},
{
name: "exists false returns unrestricted",
filter: &api.StringFieldFilter{Exists: lo.ToPtr(false)},
want: customerbalance.NewUnrestrictedFeatureFilter(),
},
{
name: "exists true is rejected",
filter: &api.StringFieldFilter{Exists: lo.ToPtr(true)},
wantErr: true,
},
{
name: "contains is rejected",
filter: &api.StringFieldFilter{Contains: lo.ToPtr("feature")},
wantErr: true,
},
{
name: "neq is rejected",
filter: &api.StringFieldFilter{Neq: lo.ToPtr("feature-a")},
wantErr: true,
},
{
name: "ocontains is rejected",
filter: &api.StringFieldFilter{Ocontains: []string{"feature"}},
wantErr: true,
},
{
name: "gt is rejected",
filter: &api.StringFieldFilter{Gt: lo.ToPtr("feature-a")},
wantErr: true,
},
{
name: "gte is rejected",
filter: &api.StringFieldFilter{Gte: lo.ToPtr("feature-a")},
wantErr: true,
},
{
name: "lt is rejected",
filter: &api.StringFieldFilter{Lt: lo.ToPtr("feature-a")},
wantErr: true,
},
{
name: "lte is rejected",
filter: &api.StringFieldFilter{Lte: lo.ToPtr("feature-a")},
wantErr: true,
},
{
name: "empty feature is rejected",
filter: &api.StringFieldFilter{Eq: lo.ToPtr("")},
wantErr: true,
},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := fromAPICustomerCreditFeatureFilter(tt.filter)
if tt.wantErr {
require.Error(t, err)
return
}

require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
Loading
Loading