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 @@ -3404,6 +3404,7 @@ export const listCreditGrantsParamsFilter = z
status: creditGrantStatus.optional(),
currency: currencyCode.optional(),
key: stringFieldFilter.optional(),
featureKey: stringFieldFilter.optional(),
})
.describe('Filter options for listing credit grants.')

Expand Down Expand Up @@ -13168,6 +13169,7 @@ export const listCreditGrantsParamsFilterWire = z
status: creditGrantStatusWire.optional(),
currency: currencyCodeWire.optional(),
key: stringFieldFilterWire.optional(),
feature_key: stringFieldFilterWire.optional(),
})
.describe('Filter options for listing credit grants.')

Expand Down
7 changes: 7 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 @@ -2199,6 +2199,13 @@ export interface ListCreditGrantsParamsFilter {
currency?: string
/** Filter credit grants by key. */
key?: StringFieldFilter
/**
* Select grants spendable on any requested feature, including grants without
* feature restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and
* `oeq` with comma-separated keys. Omit to include all features; use
* `exists=false` for grants without feature restrictions.
*/
featureKey?: StringFieldFilter
}

/** A validation issue found while processing a billing resource. */
Expand Down
8 changes: 8 additions & 0 deletions api/spec/packages/aip/src/customers/credits/operations.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ model ListCreditGrantsParamsFilter {
* Filter credit grants by key.
*/
key?: Common.StringFieldFilter;

/**
* Select grants spendable on any requested feature, including grants without
* feature restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and
* `oeq` with comma-separated keys. Omit to include all features; use
* `exists=false` for grants without feature restrictions.
*/
feature_key?: Common.StringFieldFilter;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
}

namespace Credits.Grants {
Expand Down
872 changes: 440 additions & 432 deletions api/v3/api.gen.go

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions api/v3/client/customers_credits_grants.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions api/v3/handlers/customers/credits/feature_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,43 @@ func newFeatureKeyFilterBadRequest(ctx context.Context, err error) error {
}},
)
}

// Grant listings can select multiple features because they return grants, not an
// additive balance that would double-count credit shared between features.
func fromAPICreditGrantFeatureFilters(f *api.StringFieldFilter) (mo.Option[creditpurchase.FeatureFilters], error) {
if f == nil {
return customerbalance.AllFeatureFilter(), nil
}

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

if f.Exists != nil {
if *f.Exists || f.Eq != nil || len(f.Oeq) > 0 {
return customerbalance.AllFeatureFilter(), errors.New("only exists=false without other operators is supported")
}

return customerbalance.NewUnrestrictedFeatureFilter(), nil
}

if f.Eq != nil && len(f.Oeq) > 0 {
return customerbalance.AllFeatureFilter(), errors.New("eq and oeq cannot be combined")
}

features := append(creditpurchase.FeatureFilters(nil), f.Oeq...)
if f.Eq != nil {
features = append(features, *f.Eq)
}

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

features = features.Normalize()
if err := features.Validate(); err != nil {
return customerbalance.AllFeatureFilter(), err
}

return mo.Some(features), nil
}
4 changes: 4 additions & 0 deletions api/v3/handlers/customers/credits/list_grants.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ func (h *handler) ListCreditGrants() ListCreditGrantsHandler {
}

req.Key = key
req.FeatureFilters, err = fromAPICreditGrantFeatureFilters(args.Params.Filter.FeatureKey)
if err != nil {
return ListCreditGrantsRequest{}, newFeatureKeyFilterBadRequest(ctx, err)
}
}

return req, nil
Expand Down
79 changes: 79 additions & 0 deletions api/v3/handlers/customers/credits/list_grants_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package customerscredits

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

"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/billing/creditgrant"
"github.com/openmeterio/openmeter/openmeter/ledger"
"github.com/openmeterio/openmeter/pkg/pagination"
)

type grantListService struct {
creditgrant.Service
input *creditgrant.ListInput
}

func (s *grantListService) List(_ context.Context, input creditgrant.ListInput) (pagination.Result[creditpurchase.Charge], error) {
s.input = &input
return pagination.Result[creditpurchase.Charge]{}, input.Validate()
}

type grantListServer struct {
api.ServerInterface
handler *handler
}

func (s grantListServer) ListCreditGrants(w http.ResponseWriter, r *http.Request, customerID api.ULID, params api.ListCreditGrantsParams) {
s.handler.ListCreditGrants().With(ListCreditGrantsParams{CustomerID: customerID, Params: params}).ServeHTTP(w, r)
}

func TestListCreditGrantsSpendabilityQuery(t *testing.T) {
for _, tc := range []struct {
query string
features mo.Option[ledger.FeatureFilters]
invalid bool
}{
{},
{query: "filter[feature_key]=a", features: mo.Some(ledger.FeatureFilters{"a"})},
{query: "filter[feature_key][eq]=a", features: mo.Some(ledger.FeatureFilters{"a"})},
{query: "filter[feature_key][oeq]=b,a,b", features: mo.Some(ledger.FeatureFilters{"a", "b"})},
{query: "filter[feature_key][exists]=false", features: mo.Some[ledger.FeatureFilters](nil)},
{query: "filter[unknown]=a", invalid: true},
{query: "filter[feature_key][eq]=", invalid: true},
{query: "filter[feature_key][contains]=a", invalid: true},
{query: "filter[feature_key][neq]=a", invalid: true},
{query: "filter[feature_key][exists]=true", invalid: true},
{query: "filter[feature_key][eq]=a&filter[feature_key][oeq]=b", invalid: true},
{query: "filter[feature_key][eq]=a&filter[feature_key][exists]=false", invalid: true},
} {
t.Run(tc.query, func(t *testing.T) {
// Given the generated HTTP binding and the real grant list handler.
service := &grantListService{}
h := &handler{resolveNamespace: func(context.Context) (string, error) { return "ns", nil }, creditGrantService: service}
router := api.Handler(grantListServer{handler: h})
// When a deep-object query reaches the endpoint.
req := httptest.NewRequest(http.MethodGet, "/openmeter/customers/01ARZ3NDEKTSV4RRFFQ69G5FAV/credits/grants?"+tc.query, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// Then invalid queries fail before listing and valid filters reach the service.
if tc.invalid {
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
require.Nil(t, service.input)

return
}

require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.NotNil(t, service.input)
require.Equal(t, tc.features, service.input.FeatureFilters)
})
}
}
8 changes: 8 additions & 0 deletions api/v3/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17212,6 +17212,14 @@ components:
allOf:
- $ref: '#/components/schemas/StringFieldFilter'
description: Filter credit grants by key.
feature_key:
allOf:
- $ref: '#/components/schemas/StringFieldFilter'
description: |-
Select grants spendable on any requested feature, including grants without
feature restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and
`oeq` with comma-separated keys. Omit to include all features; use
`exists=false` for grants without feature restrictions.
additionalProperties: false
description: Filter options for listing credit grants.
ListCreditTransactionsParamsFilter:
Expand Down
5 changes: 5 additions & 0 deletions openmeter/billing/charges/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,11 @@ booked for each uncovered segment, and lineage persists that same allocation.
The purchase's lifecycle transaction rolls back if a selected segment changed
before persistence. Ledger account locks precede lineage locks.

Credit grant feature filters select spendability: a requested feature includes
grants without feature restrictions. Feature `oeq` matches any requested key
without duplicating shared grants. Matching precedes pagination. These filters do not require an
active or funded grant; lifecycle status remains a separate filter.

A credit grant, payment authorization, and payment settlement are separate
durable facts. A later state cannot be inferred from the presence of an earlier
one.
Expand Down
12 changes: 12 additions & 0 deletions openmeter/billing/charges/creditpurchase/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import (
"fmt"
"time"

"github.com/samber/mo"

"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
"github.com/openmeterio/openmeter/openmeter/ledger"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/filter"
"github.com/openmeterio/openmeter/pkg/framework/entutils"
Expand Down Expand Up @@ -222,6 +225,9 @@ type ListChargesInput struct {

IncludeDeleted bool
Expands meta.Expands
// FeatureFilters selects any requested feature plus unrestricted grants.
// None selects all; Some(nil) selects only grants without feature restrictions.
FeatureFilters mo.Option[ledger.FeatureFilters]
}

type ListChargesExpirationFilter struct {
Expand Down Expand Up @@ -278,6 +284,12 @@ func (i ListChargesInput) Validate() error {
errs = append(errs, fmt.Errorf("expands: %w", err))
}

if features, ok := i.FeatureFilters.Get(); ok {
if err := features.Validate(); err != nil {
errs = append(errs, fmt.Errorf("feature filters: %w", err))
}
}

return models.NewNillableGenericValidationError(errors.Join(errs...))
}

Expand Down
13 changes: 13 additions & 0 deletions openmeter/billing/charges/creditpurchase/adapter/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"

"entgo.io/ent/dialect/sql"
"github.com/oklog/ulid/v2"
"github.com/samber/lo"

Expand All @@ -15,6 +16,7 @@ import (
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis"
"github.com/openmeterio/openmeter/openmeter/ent/db"
dbchargecreditpurchase "github.com/openmeterio/openmeter/openmeter/ent/db/chargecreditpurchase"
"github.com/openmeterio/openmeter/openmeter/ledger/creditfilterquery"
"github.com/openmeterio/openmeter/pkg/filter"
"github.com/openmeterio/openmeter/pkg/framework/entutils"
"github.com/openmeterio/openmeter/pkg/models"
Expand Down Expand Up @@ -338,6 +340,17 @@ func (a *adapter) ListCharges(ctx context.Context, input creditpurchase.ListChar
}
}

if features, ok := input.FeatureFilters.Get(); ok {
query = query.Where(func(s *sql.Selector) {
predicates := []*sql.Predicate{creditfilterquery.ExactFeaturesPredicate(s.C, nil)}
for _, feature := range features {
predicates = append(predicates, creditfilterquery.MatchFeaturePredicate(s.C, feature))
}

s.Where(sql.Or(predicates...))
})
}

query = filter.ApplyToQuery(query, input.Key, dbchargecreditpurchase.FieldKey)

query = withExpands(query, input.Expands)
Expand Down
10 changes: 10 additions & 0 deletions openmeter/billing/creditgrant/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/alpacahq/alpacadecimal"
"github.com/samber/mo"

"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
Expand Down Expand Up @@ -253,6 +254,9 @@ type ListInput struct {
Status *GrantStatus
Currency *currencyx.Code
Key *filter.FilterString
// FeatureFilters selects any requested feature plus unrestricted grants.
// None selects all; Some(nil) selects only grants without feature restrictions.
FeatureFilters mo.Option[ledger.FeatureFilters]
}

func (i ListInput) Validate() error {
Expand Down Expand Up @@ -284,6 +288,12 @@ func (i ListInput) Validate() error {
}
}

if features, ok := i.FeatureFilters.Get(); ok {
if err := features.Validate(); err != nil {
errs = append(errs, fmt.Errorf("feature filters: %w", err))
}
}

return models.NewNillableGenericValidationError(errors.Join(errs...))
}

Expand Down
1 change: 1 addition & 0 deletions openmeter/billing/creditgrant/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ func (s *service) List(ctx context.Context, input creditgrant.ListInput) (pagina
}

listInput.Key = input.Key
listInput.FeatureFilters = input.FeatureFilters

return s.creditPurchaseService.List(ctx, listInput)
}
Expand Down
10 changes: 6 additions & 4 deletions openmeter/ledger/account/adapter/subaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"github.com/openmeterio/openmeter/openmeter/ent/db/predicate"
"github.com/openmeterio/openmeter/openmeter/ledger"
ledgeraccount "github.com/openmeterio/openmeter/openmeter/ledger/account"
"github.com/openmeterio/openmeter/openmeter/ledger/internal/routequery"
"github.com/openmeterio/openmeter/openmeter/ledger/creditfilterquery"
"github.com/openmeterio/openmeter/pkg/framework/entutils"
"github.com/openmeterio/openmeter/pkg/models"
)
Expand Down Expand Up @@ -206,15 +206,17 @@ func (r *repo) ListSubAccounts(ctx context.Context, input ledgeraccount.ListSubA
}

if exact, ok := normalizedRoute.CreditFilters.Get(); ok {
routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(routequery.ExactFiltersPredicate(s.C, exact)) })
routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFiltersPredicate(s.C, exact)) })
}

if features, ok := normalizedRoute.Features.Get(); ok {
routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(routequery.ExactFeaturesPredicate(s.C, features)) })
routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFeaturesPredicate(s.C, features)) })
}

if normalizedRoute.MatchFeature != "" {
routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(routequery.MatchFeaturePredicate(s.C, normalizedRoute.MatchFeature)) })
routePredicates = append(routePredicates, func(s *sql.Selector) {
s.Where(creditfilterquery.MatchFeaturePredicate(s.C, normalizedRoute.MatchFeature))
})
}

if normalizedRoute.CostBasis.IsPresent() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package routequery
package creditfilterquery

import (
"encoding/json"
Expand Down
Loading
Loading