Skip to content

Commit b0b9d94

Browse files
committed
feat(ledger): implement FeatureFilters for balance
1 parent d8b5c00 commit b0b9d94

27 files changed

Lines changed: 1165 additions & 363 deletions

File tree

api/spec/packages/aip-client-javascript/src/models/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2415,6 +2415,7 @@ export const listCreditGrantsParamsFilter = z
24152415
export const getCreditBalanceParamsFilter = z
24162416
.object({
24172417
currency: stringFieldFilterExact.optional(),
2418+
feature_key: stringFieldFilter.optional(),
24182419
})
24192420
.describe('Filter options for getting a credit balance.')
24202421

api/spec/packages/aip-client-javascript/src/models/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,21 @@ export interface ListCreditGrantsParamsFilter {
12591259
export interface GetCreditBalanceParamsFilter {
12601260
/** Filter credit balance by currency. */
12611261
currency?: string | { eq?: string; oeq?: string[]; neq?: string }
1262+
/** Filter credit balance by feature key. Omit to return the total portfolio value. Use `exists=false` to return only unrestricted balance. */
1263+
feature_key?:
1264+
| string
1265+
| {
1266+
eq?: string
1267+
neq?: string
1268+
contains?: string
1269+
ocontains?: string[]
1270+
oeq?: string[]
1271+
gt?: string
1272+
gte?: string
1273+
lt?: string
1274+
lte?: string
1275+
exists?: boolean
1276+
}
12621277
}
12631278

12641279
/** Filter options for listing charges. */

api/spec/packages/aip/src/customers/credits/operations.tsp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ model GetCreditBalanceParamsFilter {
151151
* Filter credit balance by currency.
152152
*/
153153
currency?: Common.StringFieldFilterExact;
154+
155+
/**
156+
* Filter credit balance by feature key. Omit to return the total portfolio value.
157+
* Use `exists=false` to return only unrestricted balance.
158+
*/
159+
feature_key?: Common.StringFieldFilter;
154160
}
155161

156162
namespace Credits.Balance {

api/v3/api.gen.go

Lines changed: 275 additions & 270 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v3/filters/parse.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,17 @@ func parseFilterString(qs url.Values, field string) (FilterString, error) {
325325
}
326326
f.Ocontains = items
327327
case OpExists:
328-
f.Exists = lo.ToPtr(true)
328+
exists, err := parseOptionalBool(field, OpExists, p.value)
329+
if err != nil {
330+
return err
331+
}
332+
f.Exists = &exists
329333
case OpNexists:
330-
f.Exists = lo.ToPtr(false)
334+
nexists, err := parseOptionalBool(field, OpNexists, p.value)
335+
if err != nil {
336+
return err
337+
}
338+
f.Exists = lo.ToPtr(!nexists)
331339
default:
332340
return fieldError(field, p.op, ErrUnsupportedOperator)
333341
}
@@ -337,6 +345,19 @@ func parseFilterString(qs url.Values, field string) (FilterString, error) {
337345
return f, err
338346
}
339347

348+
func parseOptionalBool(field, op, value string) (bool, error) {
349+
if value == "" {
350+
return true, nil
351+
}
352+
353+
valueBool, err := strconv.ParseBool(value)
354+
if err != nil {
355+
return false, fieldError(field, op, fmt.Errorf("invalid boolean %q", value))
356+
}
357+
358+
return valueBool, nil
359+
}
360+
340361
// parseFilterStringExact extracts a FilterStringExact supporting eq, neq, and oeq.
341362
func parseFilterStringExact(qs url.Values, field string) (FilterStringExact, error) {
342363
var f FilterStringExact

api/v3/filters/parse_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,61 @@ func TestParse_FilterString(t *testing.T) {
113113
})
114114

115115
t.Run("exists", func(t *testing.T) {
116+
var f testFilter
117+
require.NoError(t, Parse(url.Values{"filter[field][exists]": {"true"}}, &f))
118+
require.NotNil(t, f.Field)
119+
assert.Equal(t, lo.ToPtr(true), f.Field.Exists)
120+
})
121+
122+
t.Run("exists false", func(t *testing.T) {
123+
var f testFilter
124+
require.NoError(t, Parse(url.Values{"filter[field][exists]": {"false"}}, &f))
125+
require.NotNil(t, f.Field)
126+
assert.Equal(t, lo.ToPtr(false), f.Field.Exists)
127+
})
128+
129+
t.Run("exists without value", func(t *testing.T) {
116130
var f testFilter
117131
require.NoError(t, Parse(url.Values{"filter[field][exists]": {""}}, &f))
118132
require.NotNil(t, f.Field)
119133
assert.Equal(t, lo.ToPtr(true), f.Field.Exists)
120134
})
121135

136+
t.Run("exists invalid boolean", func(t *testing.T) {
137+
var f testFilter
138+
err := Parse(url.Values{"filter[field][exists]": {"anything"}}, &f)
139+
require.Error(t, err)
140+
assert.Contains(t, err.Error(), `invalid boolean "anything"`)
141+
})
142+
122143
t.Run("nexists", func(t *testing.T) {
144+
var f testFilter
145+
require.NoError(t, Parse(url.Values{"filter[field][nexists]": {"true"}}, &f))
146+
require.NotNil(t, f.Field)
147+
assert.Equal(t, lo.ToPtr(false), f.Field.Exists)
148+
})
149+
150+
t.Run("nexists false", func(t *testing.T) {
151+
var f testFilter
152+
require.NoError(t, Parse(url.Values{"filter[field][nexists]": {"false"}}, &f))
153+
require.NotNil(t, f.Field)
154+
assert.Equal(t, lo.ToPtr(true), f.Field.Exists)
155+
})
156+
157+
t.Run("nexists without value", func(t *testing.T) {
123158
var f testFilter
124159
require.NoError(t, Parse(url.Values{"filter[field][nexists]": {""}}, &f))
125160
require.NotNil(t, f.Field)
126161
assert.Equal(t, lo.ToPtr(false), f.Field.Exists)
127162
})
128163

164+
t.Run("nexists invalid boolean", func(t *testing.T) {
165+
var f testFilter
166+
err := Parse(url.Values{"filter[field][nexists]": {"anything"}}, &f)
167+
require.Error(t, err)
168+
assert.Contains(t, err.Error(), `invalid boolean "anything"`)
169+
})
170+
129171
t.Run("bare key existence", func(t *testing.T) {
130172
var f testFilter
131173
require.NoError(t, Parse(url.Values{"filter[field]": {""}}, &f))

api/v3/handlers/customers/credits/get_balance.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"net/http"
77
"time"
88

9+
"github.com/samber/mo"
10+
911
api "github.com/openmeterio/openmeter/api/v3"
1012
"github.com/openmeterio/openmeter/api/v3/apierrors"
13+
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
1114
"github.com/openmeterio/openmeter/openmeter/customer"
1215
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
1316
"github.com/openmeterio/openmeter/pkg/clock"
@@ -18,9 +21,10 @@ import (
1821

1922
type (
2023
GetCustomerCreditBalanceRequest struct {
21-
CustomerID customer.CustomerID
22-
Currencies customerbalance.CurrencyFilter
23-
AsOf time.Time
24+
CustomerID customer.CustomerID
25+
Currencies customerbalance.CurrencyFilter
26+
FeatureFilter mo.Option[creditpurchase.FeatureFilters]
27+
AsOf time.Time
2428
}
2529
GetCustomerCreditBalanceResponse = api.BillingCreditBalances
2630
GetCustomerCreditBalanceParams struct {
@@ -76,6 +80,15 @@ func (h *handler) GetCustomerCreditBalance() GetCustomerCreditBalanceHandler {
7680
}
7781
}
7882

83+
if args.Params.Filter != nil {
84+
featureFilter, err := fromAPICreditBalanceFeatureFilter(args.Params.Filter.FeatureKey)
85+
if err != nil {
86+
return GetCustomerCreditBalanceRequest{}, newFeatureKeyFilterBadRequest(ctx, err)
87+
}
88+
89+
request.FeatureFilter = featureFilter
90+
}
91+
7992
return request, nil
8093
},
8194
func(ctx context.Context, request GetCustomerCreditBalanceRequest) (GetCustomerCreditBalanceResponse, error) {
@@ -87,9 +100,10 @@ func (h *handler) GetCustomerCreditBalance() GetCustomerCreditBalanceHandler {
87100
}
88101

89102
balancesByCurrency, err := h.balanceFacade.GetBalances(ctx, customerbalance.GetBalancesInput{
90-
CustomerID: request.CustomerID,
91-
Currencies: request.Currencies,
92-
AsOf: &request.AsOf,
103+
CustomerID: request.CustomerID,
104+
Currencies: request.Currencies,
105+
FeatureFilter: request.FeatureFilter,
106+
AsOf: &request.AsOf,
93107
})
94108
if err != nil {
95109
return GetCustomerCreditBalanceResponse{}, err
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package customerscredits
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
"github.com/samber/mo"
9+
10+
api "github.com/openmeterio/openmeter/api/v3"
11+
"github.com/openmeterio/openmeter/api/v3/apierrors"
12+
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
13+
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
14+
)
15+
16+
func fromAPICreditBalanceFeatureFilter(f *api.StringFieldFilter) (mo.Option[creditpurchase.FeatureFilters], error) {
17+
if f == nil {
18+
return customerbalance.AllFeatureFilter(), nil
19+
}
20+
21+
if f.Exists != nil {
22+
if !*f.Exists {
23+
return customerbalance.NewUnrestrictedFeatureFilter(), nil
24+
}
25+
26+
return customerbalance.AllFeatureFilter(), errors.New("exists=true operator is not supported")
27+
}
28+
29+
if op := unsupportedCreditBalanceFeatureKeyOperator(f); op != "" {
30+
return customerbalance.AllFeatureFilter(), fmt.Errorf("%s operator is not supported", op)
31+
}
32+
33+
features := make([]string, 0, 1+len(f.Oeq))
34+
if f.Eq != nil {
35+
features = append(features, *f.Eq)
36+
}
37+
features = append(features, f.Oeq...)
38+
39+
if len(features) == 0 {
40+
return customerbalance.AllFeatureFilter(), nil
41+
}
42+
43+
featureFilter := customerbalance.NewFeatureFilter(features)
44+
if err := customerbalance.ValidateFeatureFilter(featureFilter); err != nil {
45+
return customerbalance.AllFeatureFilter(), err
46+
}
47+
48+
return featureFilter, nil
49+
}
50+
51+
func unsupportedCreditBalanceFeatureKeyOperator(f *api.StringFieldFilter) string {
52+
switch {
53+
case f.Neq != nil:
54+
return "neq"
55+
case f.Contains != nil:
56+
return "contains"
57+
case len(f.Ocontains) > 0:
58+
return "ocontains"
59+
case f.Gt != nil:
60+
return "gt"
61+
case f.Gte != nil:
62+
return "gte"
63+
case f.Lt != nil:
64+
return "lt"
65+
case f.Lte != nil:
66+
return "lte"
67+
default:
68+
return ""
69+
}
70+
}
71+
72+
func newFeatureKeyFilterBadRequest(ctx context.Context, err error) error {
73+
return apierrors.NewBadRequestError(
74+
ctx,
75+
errors.New("invalid feature_key filter"),
76+
apierrors.InvalidParameters{{
77+
Field: "filter[feature_key]",
78+
Reason: err.Error(),
79+
Source: apierrors.InvalidParamSourceQuery,
80+
}},
81+
)
82+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package customerscredits
2+
3+
import (
4+
"testing"
5+
6+
"github.com/samber/lo"
7+
"github.com/samber/mo"
8+
"github.com/stretchr/testify/require"
9+
10+
api "github.com/openmeterio/openmeter/api/v3"
11+
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
12+
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
13+
)
14+
15+
func TestParseCreditBalanceFeatureFilter(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
filter *api.StringFieldFilter
19+
want mo.Option[creditpurchase.FeatureFilters]
20+
wantErr bool
21+
}{
22+
{
23+
name: "omitted filter returns all",
24+
want: customerbalance.AllFeatureFilter(),
25+
},
26+
{
27+
name: "eq returns feature filter",
28+
filter: &api.StringFieldFilter{Eq: lo.ToPtr("feature-a")},
29+
want: customerbalance.NewFeatureFilter([]string{"feature-a"}),
30+
},
31+
{
32+
name: "single oeq returns feature filter",
33+
filter: &api.StringFieldFilter{Oeq: []string{"feature-a"}},
34+
want: customerbalance.NewFeatureFilter([]string{"feature-a"}),
35+
},
36+
{
37+
name: "multiple features are rejected",
38+
filter: &api.StringFieldFilter{Oeq: []string{"feature-a", "feature-b"}},
39+
wantErr: true,
40+
},
41+
{
42+
name: "exists false returns unrestricted",
43+
filter: &api.StringFieldFilter{Exists: lo.ToPtr(false)},
44+
want: customerbalance.NewUnrestrictedFeatureFilter(),
45+
},
46+
{
47+
name: "exists true is rejected",
48+
filter: &api.StringFieldFilter{Exists: lo.ToPtr(true)},
49+
wantErr: true,
50+
},
51+
{
52+
name: "contains is rejected",
53+
filter: &api.StringFieldFilter{Contains: lo.ToPtr("feature")},
54+
wantErr: true,
55+
},
56+
{
57+
name: "empty feature is rejected",
58+
filter: &api.StringFieldFilter{Eq: lo.ToPtr("")},
59+
wantErr: true,
60+
},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
got, err := fromAPICreditBalanceFeatureFilter(tt.filter)
66+
if tt.wantErr {
67+
require.Error(t, err)
68+
return
69+
}
70+
71+
require.NoError(t, err)
72+
require.Equal(t, tt.want, got)
73+
})
74+
}
75+
}

api/v3/openapi.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8186,6 +8186,12 @@ components:
81868186
allOf:
81878187
- $ref: '#/components/schemas/StringFieldFilterExact'
81888188
description: Filter credit balance by currency.
8189+
feature_key:
8190+
allOf:
8191+
- $ref: '#/components/schemas/StringFieldFilter'
8192+
description: |-
8193+
Filter credit balance by feature key. Omit to return the total portfolio
8194+
value. Use `exists=false` to return only unrestricted balance.
81898195
additionalProperties: false
81908196
description: Filter options for getting a credit balance.
81918197
GovernanceFeatureAccess:

0 commit comments

Comments
 (0)