diff --git a/api/spec/packages/aip-client-javascript/src/models/schemas.ts b/api/spec/packages/aip-client-javascript/src/models/schemas.ts index 1ad2d68be3..eb4622005c 100644 --- a/api/spec/packages/aip-client-javascript/src/models/schemas.ts +++ b/api/spec/packages/aip-client-javascript/src/models/schemas.ts @@ -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.') @@ -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.') diff --git a/api/spec/packages/aip-client-javascript/src/models/types.ts b/api/spec/packages/aip-client-javascript/src/models/types.ts index 21834cf5b5..7652cbbb3a 100644 --- a/api/spec/packages/aip-client-javascript/src/models/types.ts +++ b/api/spec/packages/aip-client-javascript/src/models/types.ts @@ -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. */ @@ -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. */ diff --git a/api/spec/packages/aip/src/customers/credits/operations.tsp b/api/spec/packages/aip/src/customers/credits/operations.tsp index ca645e4511..41d6360e41 100644 --- a/api/spec/packages/aip/src/customers/credits/operations.tsp +++ b/api/spec/packages/aip/src/customers/credits/operations.tsp @@ -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 { @@ -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 } diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index 2222e67b07..9bd7423bc1 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -4676,6 +4676,10 @@ type ForbiddenError struct { type GetCreditBalanceParamsFilter struct { // Currency Filter credit balance by currency. Currency *StringFieldFilterExact `json:"currency,omitempty"` + + // FeatureKey Filter credit balance by feature key. Omit to return the total portfolio value. + // Use `exists=false` to return only unrestricted balance. + FeatureKey *StringFieldFilter `json:"feature_key,omitempty"` } // GoneError defines model for GoneError. @@ -5051,6 +5055,11 @@ type ListCreditTransactionsParamsFilter struct { // Currency Filter credit transactions by currency. Currency *BillingCurrencyCode `json:"currency,omitempty"` + // FeatureKey Filter credit transactions by feature key. Omit to return all credit + // transactions. Use `exists=false` to return only unrestricted credit + // transactions. + FeatureKey *StringFieldFilter `json:"feature_key,omitempty"` + // Type Filter credit transactions by type. Type *BillingCreditTransactionType `json:"type,omitempty"` } @@ -11494,278 +11503,281 @@ var swaggerSpec = []string{ "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", + "prBKBAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v3/filters/parse.go b/api/v3/filters/parse.go index bb36f8e1ab..6a5318a83c 100644 --- a/api/v3/filters/parse.go +++ b/api/v3/filters/parse.go @@ -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) } @@ -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 diff --git a/api/v3/filters/parse_test.go b/api/v3/filters/parse_test.go index d4e8e40e14..7a62aeca07 100644 --- a/api/v3/filters/parse_test.go +++ b/api/v3/filters/parse_test.go @@ -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)) diff --git a/api/v3/handlers/customers/credits/feature_filter.go b/api/v3/handlers/customers/credits/feature_filter.go new file mode 100644 index 0000000000..e2406d6c32 --- /dev/null +++ b/api/v3/handlers/customers/credits/feature_filter.go @@ -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, + }}, + ) +} diff --git a/api/v3/handlers/customers/credits/feature_filter_test.go b/api/v3/handlers/customers/credits/feature_filter_test.go new file mode 100644 index 0000000000..52c5ed8294 --- /dev/null +++ b/api/v3/handlers/customers/credits/feature_filter_test.go @@ -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, + }, + } + + 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) + }) + } +} diff --git a/api/v3/handlers/customers/credits/get_balance.go b/api/v3/handlers/customers/credits/get_balance.go index 7369fb008e..a974f7dd21 100644 --- a/api/v3/handlers/customers/credits/get_balance.go +++ b/api/v3/handlers/customers/credits/get_balance.go @@ -6,8 +6,11 @@ import ( "net/http" "time" + "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/customer" "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance" "github.com/openmeterio/openmeter/pkg/clock" @@ -18,9 +21,10 @@ import ( type ( GetCustomerCreditBalanceRequest struct { - CustomerID customer.CustomerID - Currencies customerbalance.CurrencyFilter - AsOf time.Time + CustomerID customer.CustomerID + Currencies customerbalance.CurrencyFilter + FeatureFilter mo.Option[creditpurchase.FeatureFilters] + AsOf time.Time } GetCustomerCreditBalanceResponse = api.BillingCreditBalances GetCustomerCreditBalanceParams struct { @@ -76,6 +80,15 @@ func (h *handler) GetCustomerCreditBalance() GetCustomerCreditBalanceHandler { } } + if args.Params.Filter != nil { + featureFilter, err := fromAPICustomerCreditFeatureFilter(args.Params.Filter.FeatureKey) + if err != nil { + return GetCustomerCreditBalanceRequest{}, newFeatureKeyFilterBadRequest(ctx, err) + } + + request.FeatureFilter = featureFilter + } + return request, nil }, func(ctx context.Context, request GetCustomerCreditBalanceRequest) (GetCustomerCreditBalanceResponse, error) { @@ -87,9 +100,10 @@ func (h *handler) GetCustomerCreditBalance() GetCustomerCreditBalanceHandler { } balancesByCurrency, err := h.balanceFacade.GetBalances(ctx, customerbalance.GetBalancesInput{ - CustomerID: request.CustomerID, - Currencies: request.Currencies, - AsOf: &request.AsOf, + CustomerID: request.CustomerID, + Currencies: request.Currencies, + FeatureFilter: request.FeatureFilter, + AsOf: &request.AsOf, }) if err != nil { return GetCustomerCreditBalanceResponse{}, err diff --git a/api/v3/handlers/customers/credits/list_transactions.go b/api/v3/handlers/customers/credits/list_transactions.go index 9f57258fb8..17ad83182d 100644 --- a/api/v3/handlers/customers/credits/list_transactions.go +++ b/api/v3/handlers/customers/credits/list_transactions.go @@ -98,6 +98,13 @@ func (h *handler) ListCreditTransactions() ListCreditTransactionsHandler { currency := currencyx.Code(*args.Params.Filter.Currency) req.Currency = ¤cy } + + featureFilter, err := fromAPICustomerCreditFeatureFilter(args.Params.Filter.FeatureKey) + if err != nil { + return ListCreditTransactionsRequest{}, newFeatureKeyFilterBadRequest(ctx, err) + } + + req.FeatureFilter = featureFilter } return req, nil diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index 31d8400e6a..4deea97ab5 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -8308,6 +8308,12 @@ components: allOf: - $ref: '#/components/schemas/StringFieldFilterExact' description: Filter credit balance by currency. + feature_key: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: |- + Filter credit balance by feature key. Omit to return the total portfolio value. + Use `exists=false` to return only unrestricted balance. additionalProperties: false description: Filter options for getting a credit balance. GovernanceFeatureAccess: @@ -8803,6 +8809,13 @@ components: allOf: - $ref: '#/components/schemas/BillingCurrencyCode' description: Filter credit transactions by currency. + feature_key: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: |- + Filter credit transactions by feature key. Omit to return all credit + transactions. Use `exists=false` to return only unrestricted credit + transactions. additionalProperties: false description: Filter options for listing credit transactions. ListCurrenciesParamsFilter: diff --git a/api/v3/test/filters_test.go b/api/v3/test/filters_test.go index d73a00f700..6c3289ef6b 100644 --- a/api/v3/test/filters_test.go +++ b/api/v3/test/filters_test.go @@ -436,23 +436,25 @@ func TestFieldFilterParse(t *testing.T) { wantParse: fieldFiltersTarget{String: &filters.FilterString{Lte: lo.ToPtr("zeta")}}, }, { - // The parser uses key-name semantics for existence checks: the - // presence of [exists] sets Exists=true regardless of value, while - // [nexists] sets Exists=false. name: "string exists", query: "filter[string][exists]=true", wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(true)}}, }, { - name: "string exists value is ignored", - query: "filter[string][exists]=anything", - wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(true)}}, + name: "string exists false", + query: "filter[string][exists]=false", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(false)}}, }, { name: "string nexists", query: "filter[string][nexists]=true", wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(false)}}, }, + { + name: "string nexists false", + query: "filter[string][nexists]=false", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(true)}}, + }, // StringFieldFilterExact { diff --git a/openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity.go b/openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity.go index 09ec14e0fc..9c281cd6ad 100644 --- a/openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity.go +++ b/openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity.go @@ -6,6 +6,8 @@ import ( "slices" "entgo.io/ent/dialect/sql" + "github.com/lib/pq" + "github.com/samber/mo" "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" @@ -20,15 +22,20 @@ func (a *adapter) ListFundedCreditActivities(ctx context.Context, input creditpu } func ListFundedCreditActivities(ctx context.Context, dbClient *db.Client, input creditpurchase.ListFundedCreditActivitiesInput) (creditpurchase.ListFundedCreditActivitiesResult, error) { + creditPurchasePredicates := []predicate.ChargeCreditPurchase{ + dbchargecreditpurchase.Namespace(input.Customer.Namespace), + dbchargecreditpurchase.CustomerIDEQ(input.Customer.ID), + dbchargecreditpurchase.DeletedAtIsNil(), + } + if featurePredicate := fundedCreditActivityFeatureFilterPredicate(input.FeatureFilter); featurePredicate != nil { + creditPurchasePredicates = append(creditPurchasePredicates, featurePredicate) + } + query := dbClient.ChargeCreditPurchaseCreditGrant.Query(). Where( dbchargecreditpurchasecreditgrant.Namespace(input.Customer.Namespace), dbchargecreditpurchasecreditgrant.DeletedAtIsNil(), - dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith( - dbchargecreditpurchase.Namespace(input.Customer.Namespace), - dbchargecreditpurchase.CustomerIDEQ(input.Customer.ID), - dbchargecreditpurchase.DeletedAtIsNil(), - ), + dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith(creditPurchasePredicates...), ). WithCreditPurchase(func(q *db.ChargeCreditPurchaseQuery) { q.Where( @@ -53,11 +60,7 @@ func ListFundedCreditActivities(ctx context.Context, dbClient *db.Client, input } if input.Currency != nil { - query = query.Where( - dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith( - dbchargecreditpurchase.CurrencyEQ(*input.Currency), - ), - ) + query = query.Where(dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith(dbchargecreditpurchase.CurrencyEQ(*input.Currency))) } if input.AsOf != nil { @@ -130,6 +133,30 @@ func ListFundedCreditActivities(ctx context.Context, dbClient *db.Client, input }, nil } +func fundedCreditActivityFeatureFilterPredicate(filter mo.Option[creditpurchase.FeatureFilters]) predicate.ChargeCreditPurchase { + if filter.IsAbsent() { + return nil + } + + features := filter.OrEmpty() + if features == nil { + return dbchargecreditpurchase.FeatureFiltersIsNil() + } + features = features.Normalize() + if len(features) == 0 { + return nil + } + + return dbchargecreditpurchase.Or( + dbchargecreditpurchase.FeatureFiltersIsNil(), + func(s *sql.Selector) { + s.Where(sql.P(func(b *sql.Builder) { + b.Ident(s.C(dbchargecreditpurchase.FieldFeatureFilters)).WriteString(" @> ").Arg(pq.StringArray{features[0]}) + })) + }, + ) +} + func fundedCreditActivityAfterPredicate(cursor creditpurchase.FundedCreditActivityCursor) predicate.ChargeCreditPurchaseCreditGrant { return dbchargecreditpurchasecreditgrant.Or( dbchargecreditpurchasecreditgrant.GrantedAtLT(cursor.FundedAt), diff --git a/openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity_test.go b/openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity_test.go index 2cfd711876..d14b040e2e 100644 --- a/openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity_test.go +++ b/openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity_test.go @@ -7,7 +7,9 @@ import ( "time" "github.com/alpacahq/alpacadecimal" + "github.com/lib/pq" "github.com/oklog/ulid/v2" + "github.com/samber/mo" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -78,12 +80,13 @@ func (s *ListFundedCreditActivitiesSuite) insertCreditPurchaseWithGrant( fundedAt time.Time, name string, description *string, + features ...creditpurchase.FeatureFilters, ) meta.ChargeID { s.T().Helper() servicePeriodTo := chargeCreatedAt.Add(time.Hour) - chargeEntity, err := s.dbClient.ChargeCreditPurchase.Create(). + create := s.dbClient.ChargeCreditPurchase.Create(). SetNamespace(namespace). SetCustomerID(customerID). SetServicePeriodFrom(chargeCreatedAt). @@ -102,8 +105,12 @@ func (s *ListFundedCreditActivitiesSuite) insertCreditPurchaseWithGrant( SetCreditAmount(alpacadecimal.NewFromInt(100)). SetSettlement(creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{})). SetCreatedAt(chargeCreatedAt). - SetUpdatedAt(chargeCreatedAt). - Save(s.T().Context()) + SetUpdatedAt(chargeCreatedAt) + if len(features) > 0 && features[0] != nil { + create.SetFeatureFilters(pq.StringArray(features[0].Normalize())) + } + + chargeEntity, err := create.Save(s.T().Context()) s.Require().NoError(err) _, err = s.dbClient.ChargeCreditPurchaseCreditGrant.Create(). @@ -355,3 +362,104 @@ func (s *ListFundedCreditActivitiesSuite) TestFiltersByAsOf() { s.Equal(idVisible, result.Items[0].ChargeID) s.Equal("visible-funded", result.Items[0].Name) } + +func (s *ListFundedCreditActivitiesSuite) TestFiltersByFeatureFilter() { + ctx := context.Background() + ns := "test-funded-activity-feature-filter" + customerID := s.createCustomer(ns) + customerRef := customer.CustomerID{Namespace: ns, ID: customerID} + base := time.Now().UTC().Truncate(time.Microsecond) + + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base, + base.Add(1*time.Minute), + "unrestricted-funded", + nil, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(1*time.Minute), + base.Add(2*time.Minute), + "feature-a-funded", + nil, + creditpurchase.FeatureFilters{"feature-a"}, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(2*time.Minute), + base.Add(3*time.Minute), + "feature-a-b-funded", + nil, + creditpurchase.FeatureFilters{"feature-a", "feature-b"}, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(3*time.Minute), + base.Add(4*time.Minute), + "feature-b-funded", + nil, + creditpurchase.FeatureFilters{"feature-b"}, + ) + + all, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 10, + }) + s.Require().NoError(err) + s.Require().Equal([]string{ + "feature-b-funded", + "feature-a-b-funded", + "feature-a-funded", + "unrestricted-funded", + }, fundedActivityNames(all.Items)) + + unrestricted, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 10, + FeatureFilter: mo.Some[creditpurchase.FeatureFilters](nil), + }) + s.Require().NoError(err) + s.Require().Equal([]string{"unrestricted-funded"}, fundedActivityNames(unrestricted.Items)) + + featureA, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 10, + FeatureFilter: mo.Some(creditpurchase.FeatureFilters{"feature-a"}), + }) + s.Require().NoError(err) + s.Require().Equal([]string{ + "feature-a-b-funded", + "feature-a-funded", + "unrestricted-funded", + }, fundedActivityNames(featureA.Items)) + + featureB, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 10, + FeatureFilter: mo.Some(creditpurchase.FeatureFilters{"feature-b"}), + }) + s.Require().NoError(err) + s.Require().Equal([]string{ + "feature-b-funded", + "feature-a-b-funded", + "unrestricted-funded", + }, fundedActivityNames(featureB.Items)) +} + +func fundedActivityNames(items []creditpurchase.FundedCreditActivity) []string { + names := make([]string, 0, len(items)) + for _, item := range items { + names = append(names, item.Name) + } + + return names +} diff --git a/openmeter/billing/charges/creditpurchase/charge_test.go b/openmeter/billing/charges/creditpurchase/charge_test.go index cd26986166..1ba5df98eb 100644 --- a/openmeter/billing/charges/creditpurchase/charge_test.go +++ b/openmeter/billing/charges/creditpurchase/charge_test.go @@ -23,3 +23,21 @@ func TestFeatureFiltersValidate(t *testing.T) { require.Error(t, FeatureFilters([]string{"api-calls", "api-calls"}).Validate()) }) } + +func TestFeatureFiltersValidateAsFeatureFilter(t *testing.T) { + t.Run("valid", func(t *testing.T) { + require.NoError(t, FeatureFilters([]string{"api-calls"}).ValidateAsFeatureFilter()) + }) + + t.Run("empty", func(t *testing.T) { + require.Error(t, FeatureFilters(nil).ValidateAsFeatureFilter()) + }) + + t.Run("multiple", func(t *testing.T) { + require.Error(t, FeatureFilters([]string{"api-calls", "storage"}).ValidateAsFeatureFilter()) + }) + + t.Run("invalid feature", func(t *testing.T) { + require.Error(t, FeatureFilters([]string{""}).ValidateAsFeatureFilter()) + }) +} diff --git a/openmeter/billing/charges/creditpurchase/featurefilters.go b/openmeter/billing/charges/creditpurchase/featurefilters.go index d2bb8be269..8e1e193df1 100644 --- a/openmeter/billing/charges/creditpurchase/featurefilters.go +++ b/openmeter/billing/charges/creditpurchase/featurefilters.go @@ -29,3 +29,22 @@ func (f FeatureFilters) Validate() error { func (f FeatureFilters) Normalize() FeatureFilters { return FeatureFilters(slicesx.Normalize([]string(f))) } + +// ValidateAsFeatureFilter validates the singular customer-facing filter form. +// Credit routes may be restricted to multiple features, but a spendability +// query can only ask for one feature at a time. +func (f FeatureFilters) ValidateAsFeatureFilter() error { + switch len(f) { + case 0: + return errors.New("features are required when feature filter is restricted") + case 1: + default: + return errors.New("feature filter supports exactly one feature") + } + + if err := f.Validate(); err != nil { + return fmt.Errorf("features: %w", err) + } + + return nil +} diff --git a/openmeter/billing/charges/creditpurchase/funded_credit_activity.go b/openmeter/billing/charges/creditpurchase/funded_credit_activity.go index ae04869194..2f0d603f8a 100644 --- a/openmeter/billing/charges/creditpurchase/funded_credit_activity.go +++ b/openmeter/billing/charges/creditpurchase/funded_credit_activity.go @@ -6,6 +6,7 @@ import ( "time" "github.com/alpacahq/alpacadecimal" + "github.com/samber/mo" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" "github.com/openmeterio/openmeter/openmeter/customer" @@ -55,6 +56,8 @@ type ListFundedCreditActivitiesInput struct { Before *FundedCreditActivityCursor Currency *currencyx.Code AsOf *time.Time + + FeatureFilter mo.Option[FeatureFilters] } func (i ListFundedCreditActivitiesInput) Validate() error { @@ -94,9 +97,26 @@ func (i ListFundedCreditActivitiesInput) Validate() error { errs = append(errs, fmt.Errorf("asOf must not be zero")) } + if err := validateFeatureFilter(i.FeatureFilter); err != nil { + errs = append(errs, fmt.Errorf("feature filter: %w", err)) + } + return models.NewNillableGenericValidationError(errors.Join(errs...)) } +func validateFeatureFilter(filter mo.Option[FeatureFilters]) error { + if filter.IsAbsent() { + return nil + } + + features := filter.OrEmpty() + if features == nil { + return nil + } + + return features.ValidateAsFeatureFilter() +} + type ListFundedCreditActivitiesResult struct { Items []FundedCreditActivity NextCursor *FundedCreditActivityCursor diff --git a/openmeter/billing/creditgrant/errors.go b/openmeter/billing/creditgrant/errors.go deleted file mode 100644 index a9c1c1c581..0000000000 --- a/openmeter/billing/creditgrant/errors.go +++ /dev/null @@ -1,21 +0,0 @@ -package creditgrant - -import ( - "net/http" - - "github.com/openmeterio/openmeter/pkg/framework/commonhttp" - "github.com/openmeterio/openmeter/pkg/models" -) - -const ErrCodeCreditGrantFeatureFiltersUnsupported models.ErrorCode = "credit_grant_feature_filters_unsupported" - -func newCreditGrantFeatureFiltersUnsupportedError(featureCount int) error { - return models.NewValidationIssue( - ErrCodeCreditGrantFeatureFiltersUnsupported, - "credit grant feature filters are not supported yet", - models.WithCriticalSeverity(), - commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), - models.WithFieldString("filters", "features"), - models.WithAttribute("feature_count", featureCount), - ) -} diff --git a/openmeter/billing/creditgrant/service.go b/openmeter/billing/creditgrant/service.go index a6d2da165e..69573a689f 100644 --- a/openmeter/billing/creditgrant/service.go +++ b/openmeter/billing/creditgrant/service.go @@ -129,11 +129,6 @@ func (i CreateInput) Validate() error { if err := creditpurchase.FeatureFilters(i.Filters.Features).Validate(); err != nil { errs = append(errs, fmt.Errorf("filters.features: %w", err)) } - - // Temporary restriction until customer balance semantics are defined for feature-filtered credits. - if len(i.Filters.Features) > 0 { - errs = append(errs, newCreditGrantFeatureFiltersUnsupportedError(len(i.Filters.Features))) - } } return models.NewNillableGenericValidationError(errors.Join(errs...)) diff --git a/openmeter/ledger/account/adapter/subaccount.go b/openmeter/ledger/account/adapter/subaccount.go index a72c955664..18431fdd7c 100644 --- a/openmeter/ledger/account/adapter/subaccount.go +++ b/openmeter/ledger/account/adapter/subaccount.go @@ -180,6 +180,9 @@ func (r *repo) ListSubAccounts(ctx context.Context, input ledgeraccount.ListSubA routePredicates = append(routePredicates, dbledgersubaccountroute.Features(pq.StringArray(features))) } } + if normalizedRoute.MatchFeature != "" { + routePredicates = append(routePredicates, matchFeature(normalizedRoute.MatchFeature)) + } if normalizedRoute.CostBasis.IsPresent() { costBasis, _ := normalizedRoute.CostBasis.Get() if costBasis != nil { @@ -225,6 +228,17 @@ func (r *repo) ListSubAccounts(ctx context.Context, input ledgeraccount.ListSubA }) } +func matchFeature(feature string) predicate.LedgerSubAccountRoute { + return func(s *sql.Selector) { + s.Where(sql.Or( + sql.IsNull(s.C(dbledgersubaccountroute.FieldFeatures)), + sql.P(func(b *sql.Builder) { + b.Ident(s.C(dbledgersubaccountroute.FieldFeatures)).WriteString(" @> ").Arg(pq.StringArray{feature}) + }), + )) + } +} + func MapSubAccountData(entity *db.LedgerSubAccount) (ledgeraccount.SubAccountData, error) { if entity.Edges.Account == nil { return ledgeraccount.SubAccountData{}, fmt.Errorf("account edge is required") diff --git a/openmeter/ledger/breakage/adapter/query.go b/openmeter/ledger/breakage/adapter/query.go new file mode 100644 index 0000000000..4298f0c093 --- /dev/null +++ b/openmeter/ledger/breakage/adapter/query.go @@ -0,0 +1,133 @@ +package adapter + +import ( + "entgo.io/ent/dialect" + sql "entgo.io/ent/dialect/sql" + "github.com/lib/pq" + + dbledgerbreakagerecord "github.com/openmeterio/openmeter/openmeter/ent/db/ledgerbreakagerecord" + ledgersubaccountdb "github.com/openmeterio/openmeter/openmeter/ent/db/ledgersubaccount" + ledgersubaccountroutedb "github.com/openmeterio/openmeter/openmeter/ent/db/ledgersubaccountroute" + "github.com/openmeterio/openmeter/openmeter/ent/db/predicate" + "github.com/openmeterio/openmeter/openmeter/ledger" +) + +func expiredRecordRoutePredicate(route ledger.RouteFilter) predicate.LedgerBreakageRecord { + if route.Currency == "" && route.Features.IsAbsent() && route.MatchFeature == "" { + return nil + } + + return func(s *sql.Selector) { + s.Where(expiredRecordRouteQuery{Route: route}.predicate(s.C(dbledgerbreakagerecord.FieldFboSubAccountID))) + } +} + +type expiredRecordRouteQuery struct { + Route ledger.RouteFilter +} + +func (q expiredRecordRouteQuery) predicate(fboSubAccountIDColumn string) *sql.Predicate { + return sql.In(fboSubAccountIDColumn, q.selector()) +} + +func (q expiredRecordRouteQuery) SQL() (string, []any) { + selector := q.selector() + selector.SetDialect(dialect.Postgres) + + return selector.Query() +} + +func (q expiredRecordRouteQuery) selector() *sql.Selector { + const ( + subAccountTableAlias = "lsa" + routeTableAlias = "lsar" + ) + + subAccounts := sql.Table(ledgersubaccountdb.Table).As(subAccountTableAlias) + routes := sql.Table(ledgersubaccountroutedb.Table).As(routeTableAlias) + + selector := sql.Select(subAccounts.C(ledgersubaccountdb.FieldID)). + From(subAccounts). + Join(routes). + On(subAccounts.C(ledgersubaccountdb.FieldRouteID), routes.C(ledgersubaccountroutedb.FieldID)) + + for _, predicate := range q.selectorPredicates(routes.C, routeTableAlias) { + selector.Where(predicate) + } + + return selector +} + +func (q expiredRecordRouteQuery) selectorPredicates(routeColumn func(string) string, routeTableAlias string) []*sql.Predicate { + predicates := make([]*sql.Predicate, 0, 3) + + if q.Route.Currency != "" { + predicates = append(predicates, sql.EQ(routeColumn(ledgersubaccountroutedb.FieldCurrency), string(q.Route.Currency))) + } + + if q.Route.Features.IsPresent() { + features, _ := q.Route.Features.Get() + features = ledger.SortedFeatures(features) + if len(features) == 0 { + predicates = append(predicates, sql.IsNull(routeColumn(ledgersubaccountroutedb.FieldFeatures))) + } else { + predicates = append(predicates, postgresArrayRouteExpression{ + Column: postgresQualifiedColumn{ + TableAlias: routeTableAlias, + Field: ledgersubaccountroutedb.FieldFeatures, + }, + Operator: postgresArrayRouteOperatorEqual, + Value: pq.StringArray(features), + }.predicate()) + } + } + + if q.Route.MatchFeature != "" { + predicates = append(predicates, sql.Or( + sql.IsNull(routeColumn(ledgersubaccountroutedb.FieldFeatures)), + postgresArrayRouteExpression{ + Column: postgresQualifiedColumn{ + TableAlias: routeTableAlias, + Field: ledgersubaccountroutedb.FieldFeatures, + }, + Operator: postgresArrayRouteOperatorContains, + Value: pq.StringArray{q.Route.MatchFeature}, + }.predicate(), + )) + } + + return predicates +} + +type postgresArrayRouteOperator string + +const ( + postgresArrayRouteOperatorEqual postgresArrayRouteOperator = "=" + postgresArrayRouteOperatorContains postgresArrayRouteOperator = "@>" +) + +type postgresQualifiedColumn struct { + TableAlias string + Field string +} + +func (c postgresQualifiedColumn) ident(b *sql.Builder) { + b.Ident(c.TableAlias).WriteString(".").Ident(c.Field) +} + +type postgresArrayRouteExpression struct { + Column postgresQualifiedColumn + Operator postgresArrayRouteOperator + Value pq.StringArray +} + +func (e postgresArrayRouteExpression) predicate() *sql.Predicate { + return sql.P(func(b *sql.Builder) { + e.appendSQL(b) + }) +} + +func (e postgresArrayRouteExpression) appendSQL(b *sql.Builder) { + e.Column.ident(b) + b.WriteString(" ").WriteString(string(e.Operator)).WriteString(" ").Arg(e.Value) +} diff --git a/openmeter/ledger/breakage/adapter/query_test.go b/openmeter/ledger/breakage/adapter/query_test.go new file mode 100644 index 0000000000..5f5e4e9256 --- /dev/null +++ b/openmeter/ledger/breakage/adapter/query_test.go @@ -0,0 +1,59 @@ +package adapter + +import ( + "testing" + + "github.com/lib/pq" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/pkg/currencyx" +) + +func TestExpiredRecordRouteQuerySQL(t *testing.T) { + tests := []struct { + name string + query expiredRecordRouteQuery + wantSQL string + wantArgs []any + }{ + { + name: "exact feature route", + query: expiredRecordRouteQuery{ + Route: ledger.RouteFilter{ + Currency: currencyx.Code("USD"), + Features: mo.Some([]string{"feature-b", "feature-a"}), + }, + }, + wantSQL: `SELECT "lsa"."id" FROM "ledger_sub_accounts" AS "lsa" JOIN "ledger_sub_account_routes" AS "lsar" ON "lsa"."route_id" = "lsar"."id" WHERE "lsar"."currency" = $1 AND "lsar"."features" = $2`, + wantArgs: []any{ + "USD", + pq.StringArray{"feature-a", "feature-b"}, + }, + }, + { + name: "match feature route", + query: expiredRecordRouteQuery{ + Route: ledger.RouteFilter{ + Currency: currencyx.Code("USD"), + MatchFeature: "feature-a", + }, + }, + wantSQL: `SELECT "lsa"."id" FROM "ledger_sub_accounts" AS "lsa" JOIN "ledger_sub_account_routes" AS "lsar" ON "lsa"."route_id" = "lsar"."id" WHERE "lsar"."currency" = $1 AND ("lsar"."features" IS NULL OR "lsar"."features" @> $2)`, + wantArgs: []any{ + "USD", + pq.StringArray{"feature-a"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSQL, gotArgs := tt.query.SQL() + + require.Equal(t, tt.wantSQL, gotSQL) + require.Equal(t, tt.wantArgs, gotArgs) + }) + } +} diff --git a/openmeter/ledger/breakage/adapter/record.go b/openmeter/ledger/breakage/adapter/record.go index 30e69b3890..47c6d453c0 100644 --- a/openmeter/ledger/breakage/adapter/record.go +++ b/openmeter/ledger/breakage/adapter/record.go @@ -161,6 +161,10 @@ func (a *adapter) ListExpiredRecords(ctx context.Context, input breakage.ListExp return nil, fmt.Errorf("as of is required") } + if err := breakage.ValidateExpiredRouteFilter(input.Route); err != nil { + return nil, fmt.Errorf("route: %w", err) + } + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]breakage.Record, error) { predicates := []predicate.LedgerBreakageRecord{ dbledgerbreakagerecord.NamespaceEQ(input.CustomerID.Namespace), @@ -172,6 +176,9 @@ func (a *adapter) ListExpiredRecords(ctx context.Context, input breakage.ListExp if input.Currency != nil { predicates = append(predicates, dbledgerbreakagerecord.CurrencyEQ(*input.Currency)) } + if routePredicate := expiredRecordRoutePredicate(input.Route); routePredicate != nil { + predicates = append(predicates, routePredicate) + } rows, err := tx.db.LedgerBreakageRecord.Query(). Where(predicates...). diff --git a/openmeter/ledger/breakage/adapter/record_test.go b/openmeter/ledger/breakage/adapter/record_test.go new file mode 100644 index 0000000000..04437d639c --- /dev/null +++ b/openmeter/ledger/breakage/adapter/record_test.go @@ -0,0 +1,152 @@ +package adapter + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/oklog/ulid/v2" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/openmeter/ledger/breakage" + ledgertestutils "github.com/openmeterio/openmeter/openmeter/ledger/testutils" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestAdapter_ListExpiredRecordsFiltersByRoute(t *testing.T) { + env := ledgertestutils.NewIntegrationEnv(t, "ledger-breakage-adapter") + + a, err := New(Config{Client: env.DB}) + require.NoError(t, err) + + expiresAt := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + asOf := expiresAt.Add(time.Minute) + breakageSubAccountID := env.BreakageSubAccountWithCostBasis(t, nil).Address().SubAccountID() + + records := []breakage.Record{ + newExpiredRecord(t, env.CustomerID, env.Currency, "unrestricted", env.CustomerAccounts.FBOAccount, breakageSubAccountID, nil, expiresAt), + newExpiredRecord(t, env.CustomerID, env.Currency, "feature-a", env.CustomerAccounts.FBOAccount, breakageSubAccountID, []string{"feature-a"}, expiresAt), + newExpiredRecord(t, env.CustomerID, env.Currency, "feature-a-b", env.CustomerAccounts.FBOAccount, breakageSubAccountID, []string{"feature-a", "feature-b"}, expiresAt), + newExpiredRecord(t, env.CustomerID, env.Currency, "feature-b", env.CustomerAccounts.FBOAccount, breakageSubAccountID, []string{"feature-b"}, expiresAt), + newExpiredRecord(t, env.CustomerID, currencyx.Code("EUR"), "eur-unrestricted", env.CustomerAccounts.FBOAccount, breakageSubAccountID, nil, expiresAt), + } + require.NoError(t, a.CreateRecords(t.Context(), breakage.CreateRecordsInput{Records: records})) + + tests := []struct { + name string + route ledger.RouteFilter + want []string + }{ + { + name: "unrestricted exact route", + route: ledger.RouteFilter{ + Currency: env.Currency, + Features: mo.Some[[]string](nil), + }, + want: []string{"unrestricted"}, + }, + { + name: "feature match route includes unrestricted and containing features", + route: ledger.RouteFilter{ + Currency: env.Currency, + MatchFeature: "feature-a", + }, + want: []string{"unrestricted", "feature-a", "feature-a-b"}, + }, + { + name: "exact feature route", + route: ledger.RouteFilter{ + Currency: env.Currency, + Features: mo.Some([]string{"feature-b"}), + }, + want: []string{"feature-b"}, + }, + { + name: "currency route", + route: ledger.RouteFilter{ + Currency: currencyx.Code("EUR"), + }, + want: []string{"eur-unrestricted"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := a.ListExpiredRecords(t.Context(), breakage.ListExpiredRecordsInput{ + CustomerID: env.CustomerID, + AsOf: asOf, + Route: tt.route, + }) + require.NoError(t, err) + require.ElementsMatch(t, tt.want, recordNames(got)) + }) + } + + t.Run("feature match route with input currency", func(t *testing.T) { + got, err := a.ListExpiredRecords(t.Context(), breakage.ListExpiredRecordsInput{ + CustomerID: env.CustomerID, + Currency: &env.Currency, + AsOf: asOf, + Route: ledger.RouteFilter{ + MatchFeature: "feature-a", + }, + }) + require.NoError(t, err) + require.ElementsMatch(t, []string{"unrestricted", "feature-a", "feature-a-b"}, recordNames(got)) + }) +} + +func newExpiredRecord( + t *testing.T, + customerID customer.CustomerID, + currency currencyx.Code, + name string, + fboAccount ledger.CustomerFBOAccount, + breakageSubAccountID string, + features []string, + expiresAt time.Time, +) breakage.Record { + t.Helper() + + fboSubAccount, err := fboAccount.GetSubAccountForRoute(t.Context(), ledger.CustomerFBORouteParams{ + Currency: currency, + CreditPriority: ledger.DefaultCustomerFBOPriority, + Features: features, + }) + require.NoError(t, err) + + return breakage.Record{ + ID: models.NamespacedID{ + Namespace: customerID.Namespace, + ID: ulid.Make().String(), + }, + Kind: ledger.BreakageKindPlan, + Amount: alpacadecimal.NewFromInt(1), + CustomerID: customerID, + Currency: currency, + CreditPriority: ledger.DefaultCustomerFBOPriority, + ExpiresAt: expiresAt, + SourceKind: breakage.SourceKindCreditPurchase, + BreakageTransactionGroupID: ulid.Make().String(), + BreakageTransactionID: ulid.Make().String(), + FBOSubAccountID: fboSubAccount.Address().SubAccountID(), + BreakageSubAccountID: breakageSubAccountID, + Annotations: models.Annotations{ + "name": name, + }, + } +} + +func recordNames(records []breakage.Record) []string { + names := make([]string, 0, len(records)) + for _, record := range records { + name, _ := record.Annotations["name"].(string) + names = append(names, name) + } + + return names +} diff --git a/openmeter/ledger/breakage/breakage_impacts.go b/openmeter/ledger/breakage/breakage_impacts.go index 8750647071..e8cb6a0153 100644 --- a/openmeter/ledger/breakage/breakage_impacts.go +++ b/openmeter/ledger/breakage/breakage_impacts.go @@ -47,6 +47,10 @@ func (i ListExpiredBreakageImpactsInput) Validate() error { errs = append(errs, errors.New("after and before cannot be set together")) } + if err := ValidateExpiredRouteFilter(i.Route); err != nil { + errs = append(errs, fmt.Errorf("route: %w", err)) + } + if i.Limit < 1 { errs = append(errs, errors.New("limit must be greater than 0")) } @@ -63,6 +67,7 @@ func (s *service) ListExpiredBreakageImpacts(ctx context.Context, input ListExpi CustomerID: input.CustomerID, Currency: input.Currency, AsOf: input.AsOf, + Route: input.Route, }) if err != nil { return ListExpiredBreakageImpactsResult{}, fmt.Errorf("list expired breakage records: %w", err) @@ -149,6 +154,29 @@ func (s *service) ListExpiredBreakageImpacts(ctx context.Context, input ListExpi }, nil } +func ValidateExpiredRouteFilter(route ledger.RouteFilter) error { + var errs []error + + if route.Features.IsPresent() && route.MatchFeature != "" { + errs = append(errs, errors.New("features and match feature filters cannot be combined")) + } + + if route.Features.IsPresent() { + features, _ := route.Features.Get() + if err := ledger.ValidateFeatures(features); err != nil { + errs = append(errs, fmt.Errorf("features: %w", err)) + } + } + + if route.MatchFeature != "" { + if err := ledger.ValidateFeatures([]string{route.MatchFeature}); err != nil { + errs = append(errs, fmt.Errorf("match feature: %w", err)) + } + } + + return errors.Join(errs...) +} + func breakageImpactMatchesCursorWindow(item BreakageImpact, after, before *ledger.TransactionCursor) bool { cursor := item.Cursor() diff --git a/openmeter/ledger/breakage/service.go b/openmeter/ledger/breakage/service.go index 43b5264416..54c5f745f9 100644 --- a/openmeter/ledger/breakage/service.go +++ b/openmeter/ledger/breakage/service.go @@ -545,6 +545,10 @@ func (s *service) ListExpiredRecords(ctx context.Context, input ListExpiredRecor return nil, errors.New("as of is required") } + if err := ValidateExpiredRouteFilter(input.Route); err != nil { + return nil, fmt.Errorf("route: %w", err) + } + return s.adapter.ListExpiredRecords(ctx, input) } diff --git a/openmeter/ledger/breakage/types.go b/openmeter/ledger/breakage/types.go index 7ed28da668..d47ebddb37 100644 --- a/openmeter/ledger/breakage/types.go +++ b/openmeter/ledger/breakage/types.go @@ -123,6 +123,7 @@ type ListExpiredRecordsInput struct { CustomerID customer.CustomerID Currency *currencyx.Code AsOf time.Time + Route ledger.RouteFilter } // ListExpiredBreakageImpactsInput selects customer-visible breakage impact rows. @@ -134,6 +135,7 @@ type ListExpiredBreakageImpactsInput struct { After *ledger.TransactionCursor Before *ledger.TransactionCursor Limit int + Route ledger.RouteFilter } // ListExpiredBreakageImpactsResult contains breakage impacts ordered by diff --git a/openmeter/ledger/customerbalance/expired_loader.go b/openmeter/ledger/customerbalance/expired_loader.go index eba40a2a01..ee6d403467 100644 --- a/openmeter/ledger/customerbalance/expired_loader.go +++ b/openmeter/ledger/customerbalance/expired_loader.go @@ -23,6 +23,7 @@ func (l *expiredCreditTransactionLoader) Load(ctx context.Context, input creditT After: input.After, Before: input.Before, Limit: input.Limit, + Route: featureFilterRoute(input.FeatureFilter), }) if err != nil { return creditTransactionLoaderResult{}, fmt.Errorf("list expired breakage impacts: %w", err) @@ -30,6 +31,7 @@ func (l *expiredCreditTransactionLoader) Load(ctx context.Context, input creditT items := make([]CreditTransaction, 0, len(result.Items)) for _, impact := range result.Items { + balanceAsOf := impact.BookedAt items = append(items, CreditTransaction{ ID: impact.ID, CreatedAt: impact.CreatedAt, @@ -39,6 +41,7 @@ func (l *expiredCreditTransactionLoader) Load(ctx context.Context, input creditT Amount: impact.Amount, Name: "Expired credits", Annotations: impact.Annotations, + balanceAsOf: &balanceAsOf, }) } diff --git a/openmeter/ledger/customerbalance/expired_loader_test.go b/openmeter/ledger/customerbalance/expired_loader_test.go index 5bd008c64f..5a1737993c 100644 --- a/openmeter/ledger/customerbalance/expired_loader_test.go +++ b/openmeter/ledger/customerbalance/expired_loader_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/alpacahq/alpacadecimal" + "github.com/samber/mo" "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/openmeter/billing" @@ -112,6 +113,103 @@ func TestListCreditTransactionsExpiredBreakage(t *testing.T) { } } +func TestListCreditTransactionsExpiredBreakageFeatureFilter(t *testing.T) { + env := newTestEnv(t) + t.Cleanup(func() { + clock.UnFreeze() + clock.ResetTime() + }) + + issuedAt := time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC) + expiresAt := issuedAt.Add(10 * time.Hour) + clock.FreezeTime(issuedAt) + + for _, spec := range []struct { + amount int64 + features []string + }{ + {amount: 100}, + {amount: 10, features: []string{"feature-a"}}, + {amount: 20, features: []string{"feature-b"}}, + } { + amount := alpacadecimal.NewFromInt(spec.amount) + env.bookFBOBalanceWithFeatures(t, amount, spec.features) + env.fundOpenReceivableInCurrencyWithFeatures(t, amount, env.Currency, spec.features) + + inputs, pending, err := env.BreakageService.PlanIssuance(t.Context(), ledgerbreakage.PlanIssuanceInput{ + CustomerID: env.CustomerID, + Amount: amount, + Currency: env.Currency, + Features: spec.features, + ExpiresAt: expiresAt, + }) + require.NoError(t, err) + env.commitBreakageRecords(t, inputs, pending) + } + + expiredType := CreditTransactionTypeExpired + tests := []struct { + name string + featureFilter creditpurchase.FeatureFilters + unrestricted bool + amount int64 + balanceBefore int64 + }{ + { + name: "all routes", + amount: -130, + balanceBefore: 130, + }, + { + name: "unrestricted routes", + unrestricted: true, + amount: -100, + balanceBefore: 100, + }, + { + name: "feature a spendable routes", + featureFilter: creditpurchase.FeatureFilters{"feature-a"}, + amount: -110, + balanceBefore: 110, + }, + { + name: "feature b spendable routes", + featureFilter: creditpurchase.FeatureFilters{"feature-b"}, + amount: -120, + balanceBefore: 120, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := ListCreditTransactionsInput{ + CustomerID: env.CustomerID, + Limit: 20, + Type: &expiredType, + Currency: &env.Currency, + AsOf: &expiresAt, + } + switch { + case tt.unrestricted: + input.FeatureFilter = NewUnrestrictedFeatureFilter() + case len(tt.featureFilter) > 0: + input.FeatureFilter = NewFeatureFilter(tt.featureFilter) + } + + expired, err := env.Service.ListCreditTransactions(t.Context(), input) + require.NoError(t, err) + requireExpiredTransactions(t, issuedAt, expired.Items, []expectedExpiredListingTransaction{ + { + expiresAfter: 10 * time.Hour, + amount: tt.amount, + balanceBefore: tt.balanceBefore, + balanceAfter: 0, + }, + }) + }) + } +} + func TestListCreditTransactionsCombinesFundedConsumedAndExpired(t *testing.T) { issuedAt := time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC) @@ -188,6 +286,191 @@ func TestListCreditTransactionsCombinesFundedConsumedAndExpired(t *testing.T) { } } +func TestListCreditTransactionsFeatureFilter(t *testing.T) { + env := newTestEnv(t) + t.Cleanup(func() { + clock.UnFreeze() + clock.ResetTime() + }) + + issuedAt := time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC) + expiresAt := issuedAt.Add(24 * time.Hour) + + env.createPromotionalCreditFunding(t, issuedAt, alpacadecimal.NewFromInt(100), expiresAt) + env.createPromotionalCreditFunding(t, issuedAt.Add(time.Minute), alpacadecimal.NewFromInt(10), expiresAt, testFeatureKey) + env.createPromotionalCreditFunding(t, issuedAt.Add(2*time.Minute), alpacadecimal.NewFromInt(20), expiresAt, testFeatureKey, "storage") + env.createPromotionalCreditFunding(t, issuedAt.Add(3*time.Minute), alpacadecimal.NewFromInt(30), expiresAt, "storage") + + beforeConsumptionAsOf := issuedAt.Add(10 * time.Minute) + beforeConsumption, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ + CustomerID: env.CustomerID, + Limit: 20, + Currency: &env.Currency, + AsOf: &beforeConsumptionAsOf, + FeatureFilter: NewFeatureFilter([]string{testFeatureKey}), + }) + require.NoError(t, err) + requireCreditTransactions(t, issuedAt, beforeConsumption.Items, []expectedCreditTransaction{ + {txType: CreditTransactionTypeFunded, bookedAfter: 2 * time.Minute, amount: 20, balanceBefore: 110, balanceAfter: 130}, + {txType: CreditTransactionTypeFunded, bookedAfter: time.Minute, amount: 10, balanceBefore: 100, balanceAfter: 110}, + {txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100}, + }) + + consumedAt := issuedAt.Add(time.Hour) + clock.FreezeTime(consumedAt) + charge := env.createFlatFeeCharge(t, + alpacadecimal.NewFromInt(115), + productcatalog.CreditOnlySettlementMode, + timeutil.ClosedPeriod{ + From: consumedAt, + To: consumedAt.Add(time.Hour), + }, + testFeatureKey, + ) + env.advanceFlatFeeCharge(t, charge) + + afterConsumptionTests := []struct { + name string + asOf time.Time + featureFilter mo.Option[creditpurchase.FeatureFilters] + expected []expectedCreditTransaction + }{ + { + name: "all routes after consumption", + asOf: consumedAt.Add(time.Minute), + expected: []expectedCreditTransaction{ + {txType: CreditTransactionTypeConsumed, bookedAfter: time.Hour, amount: -115, balanceBefore: 160, balanceAfter: 45}, + {txType: CreditTransactionTypeFunded, bookedAfter: 3 * time.Minute, amount: 30, balanceBefore: 130, balanceAfter: 160}, + {txType: CreditTransactionTypeFunded, bookedAfter: 2 * time.Minute, amount: 20, balanceBefore: 110, balanceAfter: 130}, + {txType: CreditTransactionTypeFunded, bookedAfter: time.Minute, amount: 10, balanceBefore: 100, balanceAfter: 110}, + {txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100}, + }, + }, + { + name: "unrestricted routes after consumption", + asOf: consumedAt.Add(time.Minute), + featureFilter: NewUnrestrictedFeatureFilter(), + expected: []expectedCreditTransaction{ + {txType: CreditTransactionTypeConsumed, bookedAfter: time.Hour, amount: -85, balanceBefore: 100, balanceAfter: 15}, + {txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100}, + }, + }, + { + name: "matching feature spendable routes after consumption", + asOf: consumedAt.Add(time.Minute), + featureFilter: NewFeatureFilter([]string{testFeatureKey}), + expected: []expectedCreditTransaction{ + {txType: CreditTransactionTypeConsumed, bookedAfter: time.Hour, amount: -115, balanceBefore: 130, balanceAfter: 15}, + {txType: CreditTransactionTypeFunded, bookedAfter: 2 * time.Minute, amount: 20, balanceBefore: 110, balanceAfter: 130}, + {txType: CreditTransactionTypeFunded, bookedAfter: time.Minute, amount: 10, balanceBefore: 100, balanceAfter: 110}, + {txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100}, + }, + }, + { + name: "non-matching feature includes unrestricted consumed rows", + asOf: consumedAt.Add(time.Minute), + featureFilter: NewFeatureFilter([]string{"unknown"}), + expected: []expectedCreditTransaction{ + {txType: CreditTransactionTypeConsumed, bookedAfter: time.Hour, amount: -85, balanceBefore: 100, balanceAfter: 15}, + {txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100}, + }, + }, + } + + t.Run("after consumption", func(t *testing.T) { + for _, tt := range afterConsumptionTests { + t.Run(tt.name, func(t *testing.T) { + got, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ + CustomerID: env.CustomerID, + Limit: 20, + Currency: &env.Currency, + AsOf: &tt.asOf, + FeatureFilter: tt.featureFilter, + }) + require.NoError(t, err) + + requireCreditTransactions(t, issuedAt, got.Items, tt.expected) + }) + } + }) + + expiryTests := []struct { + name string + featureFilter mo.Option[creditpurchase.FeatureFilters] + expectedTypes []CreditTransactionType + expected []expectedCreditTransaction + }{ + { + name: "matching feature spendable routes after expiry", + featureFilter: NewFeatureFilter([]string{testFeatureKey}), + expectedTypes: []CreditTransactionType{ + CreditTransactionTypeExpired, + CreditTransactionTypeConsumed, + CreditTransactionTypeFunded, + CreditTransactionTypeFunded, + CreditTransactionTypeFunded, + }, + expected: []expectedCreditTransaction{ + {txType: CreditTransactionTypeExpired, bookedAfter: 24 * time.Hour, amount: -15, balanceBefore: 15, balanceAfter: 0}, + {txType: CreditTransactionTypeConsumed, bookedAfter: time.Hour, amount: -115, balanceBefore: 130, balanceAfter: 15}, + {txType: CreditTransactionTypeFunded, bookedAfter: 2 * time.Minute, amount: 20, balanceBefore: 110, balanceAfter: 130}, + {txType: CreditTransactionTypeFunded, bookedAfter: time.Minute, amount: 10, balanceBefore: 100, balanceAfter: 110}, + {txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100}, + }, + }, + { + name: "storage feature spendable routes after expiry", + featureFilter: NewFeatureFilter([]string{"storage"}), + expectedTypes: []CreditTransactionType{ + CreditTransactionTypeExpired, + CreditTransactionTypeConsumed, + CreditTransactionTypeFunded, + CreditTransactionTypeFunded, + CreditTransactionTypeFunded, + }, + expected: []expectedCreditTransaction{ + {txType: CreditTransactionTypeExpired, bookedAfter: 24 * time.Hour, amount: -45, balanceBefore: 45, balanceAfter: 0}, + {txType: CreditTransactionTypeConsumed, bookedAfter: time.Hour, amount: -105, balanceBefore: 150, balanceAfter: 45}, + {txType: CreditTransactionTypeFunded, bookedAfter: 3 * time.Minute, amount: 30, balanceBefore: 120, balanceAfter: 150}, + {txType: CreditTransactionTypeFunded, bookedAfter: 2 * time.Minute, amount: 20, balanceBefore: 100, balanceAfter: 120}, + {txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100}, + }, + }, + { + name: "non-matching feature includes unrestricted expiry rows", + featureFilter: NewFeatureFilter([]string{"unknown"}), + expectedTypes: []CreditTransactionType{ + CreditTransactionTypeExpired, + CreditTransactionTypeConsumed, + CreditTransactionTypeFunded, + }, + expected: []expectedCreditTransaction{ + {txType: CreditTransactionTypeExpired, bookedAfter: 24 * time.Hour, amount: -15, balanceBefore: 15, balanceAfter: 0}, + {txType: CreditTransactionTypeConsumed, bookedAfter: time.Hour, amount: -85, balanceBefore: 100, balanceAfter: 15}, + {txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100}, + }, + }, + } + + t.Run("after expiry", func(t *testing.T) { + for _, tt := range expiryTests { + t.Run(tt.name, func(t *testing.T) { + got, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ + CustomerID: env.CustomerID, + Limit: 20, + Currency: &env.Currency, + AsOf: &expiresAt, + FeatureFilter: tt.featureFilter, + }) + require.NoError(t, err) + + requireCreditTransactionTypes(t, got.Items, tt.expectedTypes) + requireCreditTransactions(t, issuedAt, got.Items, tt.expected) + }) + } + }) +} + type expiredListingPlanSetup struct { amount int64 release int64 @@ -311,7 +594,7 @@ func (e *testEnv) bookMixedCreditTransactionState(t *testing.T, issuedAt time.Ti e.advanceFlatFeeCharge(t, charge) } -func (e *testEnv) createPromotionalCreditFunding(t *testing.T, fundedAt time.Time, amount alpacadecimal.Decimal, expiresAt time.Time) creditpurchase.Charge { +func (e *testEnv) createPromotionalCreditFunding(t *testing.T, fundedAt time.Time, amount alpacadecimal.Decimal, expiresAt time.Time, features ...string) creditpurchase.Charge { t.Helper() clock.FreezeTime(fundedAt) @@ -336,9 +619,10 @@ func (e *testEnv) createPromotionalCreditFunding(t *testing.T, fundedAt time.Tim TaxCodeID: e.taxCodeID, }, }, - CreditAmount: amount, - ExpiresAt: &expiresAt, - Settlement: creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}), + CreditAmount: amount, + ExpiresAt: &expiresAt, + FeatureFilters: creditpurchase.FeatureFilters(features), + Settlement: creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}), }, }) require.NoError(t, err) @@ -453,6 +737,24 @@ func requireCreditTransactions( } } +func requireCreditTransactionEvents( + t *testing.T, + issuedAt time.Time, + actual []CreditTransaction, + expected []expectedCreditTransaction, +) { + t.Helper() + + require.Len(t, actual, len(expected)) + for idx, expectedItem := range expected { + item := actual[idx] + + require.Equal(t, expectedItem.txType, item.Type, "transaction type at index %d", idx) + require.True(t, issuedAt.Add(expectedItem.bookedAfter).Equal(item.BookedAt), "booked_at at index %d", idx) + require.True(t, alpacadecimal.NewFromInt(expectedItem.amount).Equal(item.Amount), "amount at index %d: %s", idx, item.Amount) + } +} + func requireCreditTransactionTypes(t *testing.T, actual []CreditTransaction, expected []CreditTransactionType) { t.Helper() diff --git a/openmeter/ledger/customerbalance/facade.go b/openmeter/ledger/customerbalance/facade.go index 82b34c35dc..6f6e662f0f 100644 --- a/openmeter/ledger/customerbalance/facade.go +++ b/openmeter/ledger/customerbalance/facade.go @@ -7,7 +7,9 @@ import ( "time" "github.com/alpacahq/alpacadecimal" + "github.com/samber/mo" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/ledger" "github.com/openmeterio/openmeter/pkg/currencyx" @@ -29,16 +31,18 @@ func (f CurrencyFilter) Validate() error { } type GetBalancesInput struct { - CustomerID customer.CustomerID - Currencies CurrencyFilter - AsOf *time.Time + CustomerID customer.CustomerID + Currencies CurrencyFilter + FeatureFilter mo.Option[creditpurchase.FeatureFilters] + AsOf *time.Time } type GetBalanceInput struct { - CustomerID customer.CustomerID - Currency currencyx.Code - After *ledger.TransactionCursor - AsOf *time.Time + CustomerID customer.CustomerID + Currency currencyx.Code + FeatureFilter mo.Option[creditpurchase.FeatureFilters] + After *ledger.TransactionCursor + AsOf *time.Time } func (i GetBalancesInput) Validate() error { @@ -52,6 +56,10 @@ func (i GetBalancesInput) Validate() error { errs = append(errs, fmt.Errorf("currencies: %w", err)) } + if err := ValidateFeatureFilter(i.FeatureFilter); err != nil { + errs = append(errs, fmt.Errorf("feature filter: %w", err)) + } + if i.AsOf != nil && i.AsOf.IsZero() { errs = append(errs, fmt.Errorf("asOf must not be zero")) } @@ -70,6 +78,10 @@ func (i GetBalanceInput) Validate() error { errs = append(errs, fmt.Errorf("currency: %w", err)) } + if err := ValidateFeatureFilter(i.FeatureFilter); err != nil { + errs = append(errs, fmt.Errorf("feature filter: %w", err)) + } + if i.After != nil { if err := i.After.Validate(); err != nil { errs = append(errs, fmt.Errorf("after: %w", err)) @@ -135,8 +147,13 @@ func (f *Facade) GetBalances(ctx context.Context, input GetBalancesInput) ([]Bal balances := make([]BalanceByCurrency, 0, len(codes)) for _, code := range codes { - balance, err := f.service.GetBalance(ctx, input.CustomerID, code, ledger.BalanceQuery{ - AsOf: input.AsOf, + balance, err := f.service.GetBalance(ctx, GetBalanceServiceInput{ + CustomerID: input.CustomerID, + Currency: code, + FeatureFilter: normalizeFeatureFilter(input.FeatureFilter), + BalanceQuery: ledger.BalanceQuery{ + AsOf: input.AsOf, + }, }) if err != nil { return nil, err @@ -160,9 +177,14 @@ func (f *Facade) GetBalance(ctx context.Context, input GetBalanceInput) (alpacad return alpacadecimal.Zero, err } - balance, err := f.service.GetBalance(ctx, input.CustomerID, input.Currency, ledger.BalanceQuery{ - After: input.After, - AsOf: input.AsOf, + balance, err := f.service.GetBalance(ctx, GetBalanceServiceInput{ + CustomerID: input.CustomerID, + Currency: input.Currency, + FeatureFilter: normalizeFeatureFilter(input.FeatureFilter), + BalanceQuery: ledger.BalanceQuery{ + After: input.After, + AsOf: input.AsOf, + }, }) if err != nil { return alpacadecimal.Zero, err diff --git a/openmeter/ledger/customerbalance/feature_filter.go b/openmeter/ledger/customerbalance/feature_filter.go new file mode 100644 index 0000000000..fb1bf3e694 --- /dev/null +++ b/openmeter/ledger/customerbalance/feature_filter.go @@ -0,0 +1,63 @@ +package customerbalance + +import ( + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/ledger" +) + +// Feature filters are represented as a tri-state option: +// - None: all credit routes / total portfolio balance. +// - Some(nil): unrestricted routes only. +// - Some(one feature): unrestricted routes plus restricted routes containing that feature. +func AllFeatureFilter() mo.Option[creditpurchase.FeatureFilters] { + return mo.None[creditpurchase.FeatureFilters]() +} + +func NewFeatureFilter(features []string) mo.Option[creditpurchase.FeatureFilters] { + return mo.Some(creditpurchase.FeatureFilters(features).Normalize()) +} + +func NewUnrestrictedFeatureFilter() mo.Option[creditpurchase.FeatureFilters] { + return mo.Some[creditpurchase.FeatureFilters](nil) +} + +func ValidateFeatureFilter(filter mo.Option[creditpurchase.FeatureFilters]) error { + if filter.IsAbsent() { + return nil + } + + features := filter.OrEmpty() + if features == nil { + return nil + } + + return features.ValidateAsFeatureFilter() +} + +func normalizeFeatureFilter(filter mo.Option[creditpurchase.FeatureFilters]) mo.Option[creditpurchase.FeatureFilters] { + if filter.IsAbsent() { + return filter + } + + features := filter.OrEmpty() + if features == nil { + return filter + } + + return mo.Some(features.Normalize()) +} + +func featureFilterRoute(featureFilter mo.Option[creditpurchase.FeatureFilters]) ledger.RouteFilter { + if featureFilter.IsAbsent() { + return ledger.RouteFilter{} + } + + features := featureFilter.OrEmpty() + if features == nil { + return ledger.RouteFilter{Features: mo.Some[[]string](nil)} + } + + return ledger.RouteFilter{MatchFeature: features[0]} +} diff --git a/openmeter/ledger/customerbalance/funded_loader.go b/openmeter/ledger/customerbalance/funded_loader.go index ec87a9bc52..030b697f40 100644 --- a/openmeter/ledger/customerbalance/funded_loader.go +++ b/openmeter/ledger/customerbalance/funded_loader.go @@ -2,6 +2,7 @@ package customerbalance import ( "context" + "fmt" "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" @@ -19,12 +20,13 @@ func newFundedCreditTransactionLoader(s *service) creditTransactionLoader { func (l *fundedCreditTransactionLoader) Load(ctx context.Context, input creditTransactionLoaderInput) (creditTransactionLoaderResult, error) { result, err := l.service.CreditPurchaseSvc.ListFundedCreditActivities(ctx, creditpurchase.ListFundedCreditActivitiesInput{ - Customer: input.CustomerID, - Limit: input.Limit, - After: toFundedCreditActivityCursor(input.After), - Before: toFundedCreditActivityCursor(input.Before), - Currency: input.Currency, - AsOf: &input.AsOf, + Customer: input.CustomerID, + Limit: input.Limit, + After: toFundedCreditActivityCursor(input.After), + Before: toFundedCreditActivityCursor(input.Before), + Currency: input.Currency, + AsOf: &input.AsOf, + FeatureFilter: input.FeatureFilter, }) if err != nil { return creditTransactionLoaderResult{}, err @@ -32,20 +34,26 @@ func (l *fundedCreditTransactionLoader) Load(ctx context.Context, input creditTr items := make([]CreditTransaction, 0, len(result.Items)) for _, activity := range result.Items { + balanceCursor, err := l.balanceCursorForFundedActivity(ctx, input.CustomerID.Namespace, activity) + if err != nil { + return creditTransactionLoaderResult{}, err + } + annotations := models.Annotations{ ledger.AnnotationChargeID: activity.ChargeID.ID, } items = append(items, CreditTransaction{ - ID: models.NamespacedID(activity.ChargeID), - CreatedAt: activity.ChargeCreatedAt, - BookedAt: activity.FundedAt, - Type: CreditTransactionTypeFunded, - Currency: activity.Currency, - Amount: activity.Amount, - Name: activity.Name, - Description: activity.Description, - Annotations: annotations, + ID: models.NamespacedID(activity.ChargeID), + CreatedAt: activity.ChargeCreatedAt, + BookedAt: activity.FundedAt, + Type: CreditTransactionTypeFunded, + Currency: activity.Currency, + Amount: activity.Amount, + Name: activity.Name, + Description: activity.Description, + Annotations: annotations, + balanceCursor: balanceCursor, }) } @@ -55,6 +63,38 @@ func (l *fundedCreditTransactionLoader) Load(ctx context.Context, input creditTr }, nil } +func (l *fundedCreditTransactionLoader) balanceCursorForFundedActivity( + ctx context.Context, + namespace string, + activity creditpurchase.FundedCreditActivity, +) (*ledger.TransactionCursor, error) { + if activity.TransactionGroupID == "" { + return nil, nil + } + + group, err := l.service.Ledger.GetTransactionGroup(ctx, models.NamespacedID{ + Namespace: namespace, + ID: activity.TransactionGroupID, + }) + if err != nil { + return nil, fmt.Errorf("get funded credit transaction group %s: %w", activity.TransactionGroupID, err) + } + + for _, tx := range group.Transactions() { + impact, currency, err := creditTransactionFBOImpact(tx) + if err != nil { + continue + } + + if currency == activity.Currency && impact.Equal(activity.Amount) { + cursor := tx.Cursor() + return &cursor, nil + } + } + + return nil, fmt.Errorf("funded credit transaction group %s has no matching customer FBO transaction", activity.TransactionGroupID) +} + func toFundedCreditActivityCursor(cursor *ledger.TransactionCursor) *creditpurchase.FundedCreditActivityCursor { if cursor == nil { return nil diff --git a/openmeter/ledger/customerbalance/ledger_loader.go b/openmeter/ledger/customerbalance/ledger_loader.go index 00a40bac76..ed1bcd69c1 100644 --- a/openmeter/ledger/customerbalance/ledger_loader.go +++ b/openmeter/ledger/customerbalance/ledger_loader.go @@ -27,6 +27,7 @@ func (l *ledgerCreditTransactionLoader) Load(ctx context.Context, input creditTr AccountIDs: []string{input.AccountID}, Currency: input.Currency, AsOf: &input.AsOf, + Route: featureFilterRoute(input.FeatureFilter), CreditMovement: l.movement, ExcludeAnnotationFilters: map[string]string{ ledger.AnnotationCollectionType: ledger.CollectionTypeBreakage, diff --git a/openmeter/ledger/customerbalance/loaders.go b/openmeter/ledger/customerbalance/loaders.go index f2d53fa152..6571a5e598 100644 --- a/openmeter/ledger/customerbalance/loaders.go +++ b/openmeter/ledger/customerbalance/loaders.go @@ -4,19 +4,23 @@ import ( "context" "time" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/ledger" "github.com/openmeterio/openmeter/pkg/currencyx" ) type creditTransactionLoaderInput struct { - Limit int - After *ledger.TransactionCursor - Before *ledger.TransactionCursor - CustomerID customer.CustomerID - AccountID string - Currency *currencyx.Code - AsOf time.Time + Limit int + After *ledger.TransactionCursor + Before *ledger.TransactionCursor + CustomerID customer.CustomerID + AccountID string + Currency *currencyx.Code + AsOf time.Time + FeatureFilter mo.Option[creditpurchase.FeatureFilters] } type creditTransactionLoaderResult struct { diff --git a/openmeter/ledger/customerbalance/noop.go b/openmeter/ledger/customerbalance/noop.go index 90fbe4a029..f9c437f831 100644 --- a/openmeter/ledger/customerbalance/noop.go +++ b/openmeter/ledger/customerbalance/noop.go @@ -24,7 +24,7 @@ type NoopService struct{} var _ Service = NoopService{} -func (NoopService) GetBalance(context.Context, customer.CustomerID, currencyx.Code, ledger.BalanceQuery) (ledger.Balance, error) { +func (NoopService) GetBalance(context.Context, GetBalanceServiceInput) (ledger.Balance, error) { return noopBalance{}, nil } diff --git a/openmeter/ledger/customerbalance/service.go b/openmeter/ledger/customerbalance/service.go index 206a1068a1..b9e09ebb6a 100644 --- a/openmeter/ledger/customerbalance/service.go +++ b/openmeter/ledger/customerbalance/service.go @@ -17,11 +17,12 @@ import ( ledgerbreakage "github.com/openmeterio/openmeter/openmeter/ledger/breakage" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" ) type Service interface { - GetBalance(ctx context.Context, customerID customer.CustomerID, currency currencyx.Code, query ledger.BalanceQuery) (ledger.Balance, error) + GetBalance(ctx context.Context, input GetBalanceServiceInput) (ledger.Balance, error) ListCreditTransactions(ctx context.Context, input ListCreditTransactionsInput) (ListCreditTransactionsResult, error) GetFBOCurrencies(ctx context.Context, customerID customer.CustomerID) ([]currencyx.Code, error) } @@ -82,6 +83,75 @@ type Config struct { Breakage ledgerbreakage.Service } +type GetBalanceServiceInput struct { + CustomerID customer.CustomerID + Currency currencyx.Code + FeatureFilter mo.Option[creditpurchase.FeatureFilters] + BalanceQuery ledger.BalanceQuery +} + +func (i GetBalanceServiceInput) Validate() error { + var errs []error + + if err := i.CustomerID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("customer ID: %w", err)) + } + + if err := i.Currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency: %w", err)) + } + + if err := ValidateFeatureFilter(i.FeatureFilter); err != nil { + errs = append(errs, fmt.Errorf("feature filter: %w", err)) + } + + if i.BalanceQuery.After != nil { + if err := i.BalanceQuery.After.Validate(); err != nil { + errs = append(errs, fmt.Errorf("balance query after: %w", err)) + } + } + + if i.BalanceQuery.AsOf != nil && i.BalanceQuery.AsOf.IsZero() { + errs = append(errs, errors.New("balance query asOf must not be zero")) + } + + if i.BalanceQuery.After != nil && i.BalanceQuery.AsOf != nil { + errs = append(errs, errors.New("balance query after and asOf cannot both be set")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i GetBalanceServiceInput) balanceQuery() ledger.BalanceQuery { + query := i.BalanceQuery + if query.After != nil || query.AsOf != nil { + return query + } + + asOf := clock.Now() + query.AsOf = &asOf + return query +} + +func (i GetBalanceServiceInput) bookedRoute() ledger.RouteFilter { + route := i.featureRoute() + route.Currency = i.Currency + + return route +} + +func (i GetBalanceServiceInput) advanceRoute() ledger.RouteFilter { + route := i.featureRoute() + route.Currency = i.Currency + route.CostBasis = mo.Some[*alpacadecimal.Decimal](nil) + + return route +} + +func (i GetBalanceServiceInput) featureRoute() ledger.RouteFilter { + return featureFilterRoute(normalizeFeatureFilter(i.FeatureFilter)) +} + func (c Config) Validate() error { var errs []error @@ -139,32 +209,30 @@ func New(config Config) (*service, error) { }, nil } -func (s *service) GetBalance(ctx context.Context, customerID customer.CustomerID, currency currencyx.Code, query ledger.BalanceQuery) (ledger.Balance, error) { - query = currentBalanceQuery(query) +func (s *service) GetBalance(ctx context.Context, input GetBalanceServiceInput) (ledger.Balance, error) { + if err := input.Validate(); err != nil { + return nil, err + } + query := input.balanceQuery() - customerAccounts, err := s.AccountResolver.GetCustomerAccounts(ctx, customerID) + customerAccounts, err := s.AccountResolver.GetCustomerAccounts(ctx, input.CustomerID) if err != nil { return nil, fmt.Errorf("get customer accounts: %w", err) } - bookedBalance, err := s.BalanceQuerier.GetAccountBalance(ctx, customerAccounts.FBOAccount, ledger.RouteFilter{ - Currency: currency, - }, query) + bookedBalance, err := s.BalanceQuerier.GetAccountBalance(ctx, customerAccounts.FBOAccount, input.bookedRoute(), query) if err != nil { return nil, fmt.Errorf("get booked balance: %w", err) } - advanceBalance, err := s.BalanceQuerier.GetAccountBalance(ctx, customerAccounts.ReceivableAccount, ledger.RouteFilter{ - Currency: currency, - CostBasis: mo.Some[*alpacadecimal.Decimal](nil), - }, query) + advanceBalance, err := s.BalanceQuerier.GetAccountBalance(ctx, customerAccounts.ReceivableAccount, input.advanceRoute(), query) if err != nil { return nil, fmt.Errorf("get advance balance: %w", err) } // Pending balance remains a current projection from open charges. // Historical cursor/as-of filtering only affects the booked/settled side for now. - impacts, err := s.getChargePendingBalanceImpacts(ctx, customerID, currency) + impacts, err := s.getChargePendingBalanceImpacts(ctx, input.CustomerID, input.Currency, normalizeFeatureFilter(input.FeatureFilter)) if err != nil { return nil, fmt.Errorf("get charge pending balance impacts: %w", err) } @@ -177,16 +245,6 @@ func (s *service) GetBalance(ctx context.Context, customerID customer.CustomerID }, nil } -func currentBalanceQuery(query ledger.BalanceQuery) ledger.BalanceQuery { - if query.After != nil || query.AsOf != nil { - return query - } - - asOf := clock.Now() - query.AsOf = &asOf - return query -} - func (s *service) GetFBOCurrencies(ctx context.Context, customerID customer.CustomerID) ([]currencyx.Code, error) { customerAccounts, err := s.AccountResolver.GetCustomerAccounts(ctx, customerID) if err != nil { @@ -217,7 +275,7 @@ func (s *service) GetFBOCurrencies(ctx context.Context, customerID customer.Cust return codes, nil } -func (s *service) getChargePendingBalanceImpacts(ctx context.Context, customerID customer.CustomerID, currency currencyx.Code) ([]Impact, error) { +func (s *service) getChargePendingBalanceImpacts(ctx context.Context, customerID customer.CustomerID, currency currencyx.Code, featureFilter mo.Option[creditpurchase.FeatureFilters]) ([]Impact, error) { items, err := pagination.CollectAll( ctx, pagination.NewPaginator(func(ctx context.Context, page pagination.Page) (pagination.Result[charges.Charge], error) { @@ -241,7 +299,7 @@ func (s *service) getChargePendingBalanceImpacts(ctx context.Context, customerID impacts := make([]Impact, 0, len(items)) for _, charge := range items { - impact, err := s.getChargePendingBalanceImpact(ctx, charge, currency) + impact, err := s.getChargePendingBalanceImpact(ctx, charge, currency, featureFilter) if err != nil { return nil, err } @@ -256,22 +314,22 @@ func (s *service) getChargePendingBalanceImpacts(ctx context.Context, customerID return impacts, nil } -func (s *service) getChargePendingBalanceImpact(ctx context.Context, charge charges.Charge, currency currencyx.Code) (*Impact, error) { +func (s *service) getChargePendingBalanceImpact(ctx context.Context, charge charges.Charge, currency currencyx.Code, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) { if !chargeHasStarted(charge) { return nil, nil } switch charge.Type() { case meta.ChargeTypeFlatFee: - return getFlatFeeChargePendingBalanceImpact(charge, currency) + return getFlatFeeChargePendingBalanceImpact(charge, currency, featureFilter) case meta.ChargeTypeUsageBased: - return s.getUsageBasedChargePendingBalanceImpact(ctx, charge, currency) + return s.getUsageBasedChargePendingBalanceImpact(ctx, charge, currency, featureFilter) default: return nil, nil } } -func getFlatFeeChargePendingBalanceImpact(charge charges.Charge, currency currencyx.Code) (*Impact, error) { +func getFlatFeeChargePendingBalanceImpact(charge charges.Charge, currency currencyx.Code, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) { flatFeeCharge, err := charge.AsFlatFeeCharge() if err != nil { return nil, fmt.Errorf("map flat fee charge: %w", err) @@ -281,10 +339,14 @@ func getFlatFeeChargePendingBalanceImpact(charge charges.Charge, currency curren return nil, nil } + if !featureFilterMatchesChargeFeatureKey(featureFilter, flatFeeCharge.Intent.FeatureKey) { + return nil, nil + } + return newImpactOrNil(charge, flatFeeCharge.State.AmountAfterProration) } -func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, charge charges.Charge, currency currencyx.Code) (*Impact, error) { +func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, charge charges.Charge, currency currencyx.Code, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) { usageBasedCharge, err := charge.AsUsageBasedCharge() if err != nil { return nil, fmt.Errorf("map usage based charge: %w", err) @@ -294,6 +356,10 @@ func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, c return nil, nil } + if !featureFilterMatchesChargeFeatureKey(featureFilter, usageBasedCharge.Intent.FeatureKey) { + return nil, nil + } + currentTotals, err := s.UsageBasedService.GetCurrentTotals(ctx, usagebased.GetCurrentTotalsInput{ ChargeID: usageBasedCharge.GetChargeID(), }) @@ -304,6 +370,23 @@ func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, c return newImpactOrNil(charges.NewCharge(currentTotals.Charge), currentTotals.DueTotals.Total) } +func featureFilterMatchesChargeFeatureKey(featureFilter mo.Option[creditpurchase.FeatureFilters], featureKey string) bool { + if featureFilter.IsAbsent() { + return true + } + + features := featureFilter.OrEmpty() + if features == nil { + return featureKey == "" + } + + if featureKey == "" { + return true + } + + return len(features) == 1 && features[0] == featureKey +} + func chargeHasStarted(charge charges.Charge) bool { now := clock.Now() diff --git a/openmeter/ledger/customerbalance/service_test.go b/openmeter/ledger/customerbalance/service_test.go index a8ea93da5f..64cd4b8374 100644 --- a/openmeter/ledger/customerbalance/service_test.go +++ b/openmeter/ledger/customerbalance/service_test.go @@ -5,13 +5,16 @@ import ( "time" "github.com/alpacahq/alpacadecimal" + "github.com/samber/mo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" + "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/ledger" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/pkg/clock" @@ -20,6 +23,129 @@ import ( "github.com/openmeterio/openmeter/pkg/timeutil" ) +func TestGetBalanceServiceInputValidate(t *testing.T) { + valid := GetBalanceServiceInput{ + CustomerID: customer.CustomerID{ + Namespace: "ns", + ID: "customer-id", + }, + Currency: currencyx.Code("USD"), + FeatureFilter: AllFeatureFilter(), + } + now := clock.Now() + validCursor := ledger.TransactionCursor{ + BookedAt: now, + CreatedAt: now, + ID: models.NamespacedID{ + Namespace: "ns", + ID: "transaction-id", + }, + } + + tests := []struct { + name string + input GetBalanceServiceInput + wantErr bool + }{ + { + name: "valid", + input: valid, + }, + { + name: "missing customer", + input: GetBalanceServiceInput{Currency: currencyx.Code("USD")}, + wantErr: true, + }, + { + name: "invalid currency", + input: GetBalanceServiceInput{ + CustomerID: valid.CustomerID, + Currency: currencyx.Code("not-a-currency"), + }, + wantErr: true, + }, + { + name: "multiple feature filters", + input: GetBalanceServiceInput{ + CustomerID: valid.CustomerID, + Currency: valid.Currency, + FeatureFilter: NewFeatureFilter([]string{"feature-a", "feature-b"}), + }, + wantErr: true, + }, + { + name: "invalid after cursor", + input: GetBalanceServiceInput{ + CustomerID: valid.CustomerID, + Currency: valid.Currency, + BalanceQuery: ledger.BalanceQuery{After: &ledger.TransactionCursor{}}, + }, + wantErr: true, + }, + { + name: "zero as of", + input: GetBalanceServiceInput{ + CustomerID: valid.CustomerID, + Currency: valid.Currency, + BalanceQuery: ledger.BalanceQuery{AsOf: &time.Time{}}, + }, + wantErr: true, + }, + { + name: "after and as of both set", + input: GetBalanceServiceInput{ + CustomerID: valid.CustomerID, + Currency: valid.Currency, + BalanceQuery: ledger.BalanceQuery{ + After: &validCursor, + AsOf: &now, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.input.Validate() + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestGetBalanceServiceInputRoutes(t *testing.T) { + input := GetBalanceServiceInput{ + Currency: currencyx.Code("USD"), + FeatureFilter: NewFeatureFilter([]string{"feature-a"}), + } + + bookedRoute := input.bookedRoute() + require.Equal(t, currencyx.Code("USD"), bookedRoute.Currency) + require.Equal(t, "feature-a", bookedRoute.MatchFeature) + require.True(t, bookedRoute.CostBasis.IsAbsent()) + + advanceRoute := input.advanceRoute() + require.Equal(t, currencyx.Code("USD"), advanceRoute.Currency) + require.Equal(t, "feature-a", advanceRoute.MatchFeature) + require.True(t, advanceRoute.CostBasis.IsPresent()) + costBasis, _ := advanceRoute.CostBasis.Get() + require.Nil(t, costBasis) + + unrestrictedRoute := GetBalanceServiceInput{ + Currency: currencyx.Code("USD"), + FeatureFilter: NewUnrestrictedFeatureFilter(), + }.bookedRoute() + require.True(t, unrestrictedRoute.Features.IsPresent()) + features, _ := unrestrictedRoute.Features.Get() + require.Empty(t, features) + require.Empty(t, unrestrictedRoute.MatchFeature) +} + func TestGetBalance(t *testing.T) { tests := []struct { name string @@ -140,7 +266,11 @@ func TestGetBalance(t *testing.T) { env := newTestEnv(t) tt.setup(t, env) - balance, err := env.Service.GetBalance(t.Context(), env.CustomerID, env.Currency, ledger.BalanceQuery{}) + balance, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, + Currency: env.Currency, + FeatureFilter: AllFeatureFilter(), + }) require.NoError(t, err) assert.True(t, balance.Settled().Equal(alpacadecimal.NewFromInt(tt.wantSettled)), "settled: %s", balance.Settled()) assert.True(t, balance.Pending().Equal(alpacadecimal.NewFromInt(tt.wantPending)), "pending: %s", balance.Pending()) @@ -241,13 +371,162 @@ func TestGetBalanceWithDifferentCurrency(t *testing.T) { env.createFlatFeeChargeInCurrency(t, alpacadecimal.NewFromInt(30), productcatalog.CreditOnlySettlementMode, env.sp(), "USD") env.createFlatFeeChargeInCurrency(t, alpacadecimal.NewFromInt(70), productcatalog.CreditOnlySettlementMode, env.sp(), "EUR") - usdBalance, err := env.Service.GetBalance(t.Context(), env.CustomerID, currencyx.Code("USD"), ledger.BalanceQuery{}) + usdBalance, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, + Currency: currencyx.Code("USD"), + FeatureFilter: AllFeatureFilter(), + }) require.NoError(t, err) require.True(t, usdBalance.Settled().Equal(alpacadecimal.NewFromInt(100))) require.True(t, usdBalance.Pending().Equal(alpacadecimal.NewFromInt(70))) - eurBalance, err := env.Service.GetBalance(t.Context(), env.CustomerID, currencyx.Code("EUR"), ledger.BalanceQuery{}) + eurBalance, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, + Currency: currencyx.Code("EUR"), + FeatureFilter: AllFeatureFilter(), + }) require.NoError(t, err) require.True(t, eurBalance.Settled().Equal(alpacadecimal.NewFromInt(200))) require.True(t, eurBalance.Pending().Equal(alpacadecimal.NewFromInt(130))) } + +func TestGetBalanceFeatureFilter(t *testing.T) { + env := newTestEnv(t) + + unrestricted := alpacadecimal.NewFromInt(100) + featureA := alpacadecimal.NewFromInt(10) + featureAOrB := alpacadecimal.NewFromInt(10) + + env.bookFBOBalanceWithFeatures(t, unrestricted, nil) + env.fundOpenReceivableWithFeatures(t, unrestricted, nil) + env.bookFBOBalanceWithFeatures(t, featureA, []string{"feature-a"}) + env.fundOpenReceivableWithFeatures(t, featureA, []string{"feature-a"}) + env.bookFBOBalanceWithFeatures(t, featureAOrB, []string{"feature-a", "feature-b"}) + env.fundOpenReceivableWithFeatures(t, featureAOrB, []string{"feature-a", "feature-b"}) + + tests := []struct { + name string + filter mo.Option[creditpurchase.FeatureFilters] + want float64 + }{ + { + name: "omitted filter returns total portfolio balance", + filter: AllFeatureFilter(), + want: 120, + }, + { + name: "unrestricted filter returns routes without feature restrictions", + filter: NewUnrestrictedFeatureFilter(), + want: 100, + }, + { + name: "feature A filter includes unrestricted and overlapping restricted routes", + filter: NewFeatureFilter([]string{"feature-a"}), + want: 120, + }, + { + name: "feature B filter includes unrestricted and A-or-B route", + filter: NewFeatureFilter([]string{"feature-b"}), + want: 110, + }, + { + name: "unknown feature filter includes unrestricted routes only", + filter: NewFeatureFilter([]string{"feature-c"}), + want: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + balance, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, + Currency: env.Currency, + FeatureFilter: tt.filter, + }) + require.NoError(t, err) + + require.Equal(t, tt.want, balance.Settled().InexactFloat64()) + require.Equal(t, tt.want, balance.Pending().InexactFloat64()) + }) + } + + _, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, + Currency: env.Currency, + FeatureFilter: NewFeatureFilter([]string{"feature-a", "feature-b"}), + }) + require.Error(t, err) + + customerAccounts, err := env.Deps.ResolversService.GetCustomerAccounts(t.Context(), env.CustomerID) + require.NoError(t, err) + + now := clock.Now() + exactFeatureABalance, err := env.Deps.HistoricalLedger.GetAccountBalance(t.Context(), customerAccounts.FBOAccount, ledger.RouteFilter{ + Currency: env.Currency, + Features: mo.Some([]string{"feature-a"}), + }, ledger.BalanceQuery{AsOf: &now}) + require.NoError(t, err) + require.Equal(t, float64(10), exactFeatureABalance.Settled().InexactFloat64()) +} + +func TestGetBalanceFeatureFilterPendingChargeImpacts(t *testing.T) { + env := newTestEnv(t) + + env.bookFBOBalanceWithFeatures(t, alpacadecimal.NewFromInt(100), nil) + env.fundOpenReceivableWithFeatures(t, alpacadecimal.NewFromInt(100), nil) + env.bookFBOBalanceWithFeatures(t, alpacadecimal.NewFromInt(10), []string{testFeatureKey}) + env.fundOpenReceivableWithFeatures(t, alpacadecimal.NewFromInt(10), []string{testFeatureKey}) + env.bookFBOBalanceWithFeatures(t, alpacadecimal.NewFromInt(20), []string{"storage"}) + env.fundOpenReceivableWithFeatures(t, alpacadecimal.NewFromInt(20), []string{"storage"}) + + env.addUsage(30, clock.Now().Add(-30*time.Minute)) + env.createFlatFeeCharge(t, alpacadecimal.NewFromInt(5), productcatalog.CreditOnlySettlementMode, env.sp()) + env.createFlatFeeCharge(t, alpacadecimal.NewFromInt(7), productcatalog.CreditOnlySettlementMode, env.sp(), testFeatureKey) + env.createUsageBasedCharge(t, alpacadecimal.NewFromInt(1), productcatalog.CreditOnlySettlementMode, env.sp()) + + tests := []struct { + name string + filter mo.Option[creditpurchase.FeatureFilters] + wantSettled float64 + wantPending float64 + }{ + { + name: "omitted filter includes every charge impact", + filter: AllFeatureFilter(), + wantSettled: 130, + wantPending: 88, + }, + { + name: "unrestricted filter includes only unrestricted charge impacts", + filter: NewUnrestrictedFeatureFilter(), + wantSettled: 100, + wantPending: 95, + }, + { + name: "matching feature filter includes unrestricted and matching charge impacts", + filter: NewFeatureFilter([]string{testFeatureKey}), + wantSettled: 110, + wantPending: 68, + }, + { + name: "non-matching feature filter excludes restricted charge impacts for other features", + filter: NewFeatureFilter([]string{"storage"}), + wantSettled: 120, + wantPending: 115, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + balance, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, + Currency: env.Currency, + FeatureFilter: tt.filter, + }) + require.NoError(t, err) + + require.Equal(t, tt.wantSettled, balance.Settled().InexactFloat64()) + require.Equal(t, tt.wantPending, balance.Pending().InexactFloat64()) + }) + } +} diff --git a/openmeter/ledger/customerbalance/testenv_test.go b/openmeter/ledger/customerbalance/testenv_test.go index 756cd0cf4e..668dbd4479 100644 --- a/openmeter/ledger/customerbalance/testenv_test.go +++ b/openmeter/ledger/customerbalance/testenv_test.go @@ -340,6 +340,14 @@ func (e *testEnv) bookFBOBalance(t *testing.T, amount alpacadecimal.Decimal) { } func (e *testEnv) bookFBOBalanceInCurrency(t *testing.T, amount alpacadecimal.Decimal, currency currencyx.Code) { + e.bookFBOBalanceInCurrencyWithFeatures(t, amount, currency, nil) +} + +func (e *testEnv) bookFBOBalanceWithFeatures(t *testing.T, amount alpacadecimal.Decimal, features []string) { + e.bookFBOBalanceInCurrencyWithFeatures(t, amount, e.Currency, features) +} + +func (e *testEnv) bookFBOBalanceInCurrencyWithFeatures(t *testing.T, amount alpacadecimal.Decimal, currency currencyx.Code, features []string) { t.Helper() inputs, err := transactions.ResolveTransactions( @@ -357,6 +365,7 @@ func (e *testEnv) bookFBOBalanceInCurrency(t *testing.T, amount alpacadecimal.De At: e.Now(), Amount: amount, Currency: currency, + Features: features, }, ) require.NoError(t, err) @@ -370,6 +379,14 @@ func (e *testEnv) fundOpenReceivable(t *testing.T, amount alpacadecimal.Decimal) } func (e *testEnv) fundOpenReceivableInCurrency(t *testing.T, amount alpacadecimal.Decimal, currency currencyx.Code) { + e.fundOpenReceivableInCurrencyWithFeatures(t, amount, currency, nil) +} + +func (e *testEnv) fundOpenReceivableWithFeatures(t *testing.T, amount alpacadecimal.Decimal, features []string) { + e.fundOpenReceivableInCurrencyWithFeatures(t, amount, e.Currency, features) +} + +func (e *testEnv) fundOpenReceivableInCurrencyWithFeatures(t *testing.T, amount alpacadecimal.Decimal, currency currencyx.Code, features []string) { t.Helper() inputs, err := transactions.ResolveTransactions( @@ -387,11 +404,13 @@ func (e *testEnv) fundOpenReceivableInCurrency(t *testing.T, amount alpacadecima At: e.Now(), Amount: amount, Currency: currency, + Features: features, }, transactions.SettleCustomerReceivableFromPaymentTemplate{ At: e.Now(), Amount: amount, Currency: currency, + Features: features, }, ) require.NoError(t, err) @@ -437,15 +456,23 @@ func (e *testEnv) createUsageBasedChargeInCurrency(t *testing.T, unitPrice alpac return createdCharges[0].Charge } -func (e *testEnv) createFlatFeeCharge(t *testing.T, amount alpacadecimal.Decimal, settlementMode productcatalog.SettlementMode, servicePeriod timeutil.ClosedPeriod) flatfee.Charge { - return e.createFlatFeeChargeInCurrency(t, amount, settlementMode, servicePeriod, e.Currency) +func (e *testEnv) createFlatFeeCharge(t *testing.T, amount alpacadecimal.Decimal, settlementMode productcatalog.SettlementMode, servicePeriod timeutil.ClosedPeriod, featureKeys ...string) flatfee.Charge { + return e.createFlatFeeChargeInCurrency(t, amount, settlementMode, servicePeriod, e.Currency, featureKeys...) } -func (e *testEnv) createFlatFeeChargeInCurrency(t *testing.T, amount alpacadecimal.Decimal, settlementMode productcatalog.SettlementMode, servicePeriod timeutil.ClosedPeriod, currency currencyx.Code) flatfee.Charge { +func (e *testEnv) createFlatFeeChargeInCurrency(t *testing.T, amount alpacadecimal.Decimal, settlementMode productcatalog.SettlementMode, servicePeriod timeutil.ClosedPeriod, currency currencyx.Code, featureKeys ...string) flatfee.Charge { t.Helper() + require.LessOrEqual(t, len(featureKeys), 1) + + featureKey := "" + if len(featureKeys) == 1 { + featureKey = featureKeys[0] + } + createdCharges, err := e.flatFeeService.Create(t.Context(), flatfee.CreateInput{ - Namespace: e.Namespace, + Namespace: e.Namespace, + FeatureMeters: e.featureMeters, Intents: []flatfee.Intent{ { Intent: chargemeta.Intent{ @@ -463,6 +490,7 @@ func (e *testEnv) createFlatFeeChargeInCurrency(t *testing.T, amount alpacadecim InvoiceAt: e.Now().Add(-time.Minute), SettlementMode: settlementMode, PaymentTerm: productcatalog.InAdvancePaymentTerm, + FeatureKey: featureKey, AmountBeforeProration: amount, }, }, diff --git a/openmeter/ledger/customerbalance/transactions.go b/openmeter/ledger/customerbalance/transactions.go index c6f5bad555..5a9b16881b 100644 --- a/openmeter/ledger/customerbalance/transactions.go +++ b/openmeter/ledger/customerbalance/transactions.go @@ -8,8 +8,10 @@ import ( "github.com/alpacahq/alpacadecimal" "github.com/samber/lo" + "github.com/samber/mo" "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/ledger" @@ -44,6 +46,8 @@ type ListCreditTransactionsInput struct { Type *CreditTransactionType Currency *currencyx.Code AsOf *time.Time + + FeatureFilter mo.Option[creditpurchase.FeatureFilters] } func (i ListCreditTransactionsInput) Validate() error { @@ -89,6 +93,10 @@ func (i ListCreditTransactionsInput) Validate() error { errs = append(errs, fmt.Errorf("asOf must not be zero")) } + if err := ValidateFeatureFilter(i.FeatureFilter); err != nil { + errs = append(errs, fmt.Errorf("feature filter: %w", err)) + } + return models.NewNillableGenericValidationError(errors.Join(errs...)) } @@ -103,6 +111,9 @@ type CreditTransaction struct { Name string Description *string Annotations models.Annotations + + balanceCursor *ledger.TransactionCursor + balanceAsOf *time.Time } type CreditTransactionBalance struct { @@ -135,13 +146,14 @@ func (s *service) ListCreditTransactions(ctx context.Context, input ListCreditTr } loaderInput := creditTransactionLoaderInput{ - Limit: input.Limit, - After: input.After, - Before: input.Before, - CustomerID: input.CustomerID, - AccountID: accountID, - Currency: input.Currency, - AsOf: creditTransactionsAsOf(input.AsOf), + Limit: input.Limit, + After: input.After, + Before: input.Before, + CustomerID: input.CustomerID, + AccountID: accountID, + Currency: input.Currency, + AsOf: creditTransactionsAsOf(input.AsOf), + FeatureFilter: normalizeFeatureFilter(input.FeatureFilter), } loadedLists := make([][]CreditTransaction, 0, len(loaders)) @@ -169,8 +181,11 @@ func (s *service) ListCreditTransactions(ctx context.Context, input ListCreditTr s.applyChargeMetadataToCreditTransactions(ctx, input.CustomerID.Namespace, items) if len(items) > 0 { - runningBalance, err := s.GetBalance(ctx, input.CustomerID, items[0].Currency, ledger.BalanceQuery{ - After: lo.ToPtr(creditTransactionCursor(items[0])), + runningBalance, err := s.GetBalance(ctx, GetBalanceServiceInput{ + CustomerID: input.CustomerID, + Currency: items[0].Currency, + FeatureFilter: normalizeFeatureFilter(input.FeatureFilter), + BalanceQuery: items[0].balanceQuery(), }) if err != nil { return ListCreditTransactionsResult{}, fmt.Errorf("get FBO balance after transaction %s: %w", items[0].ID.ID, err) @@ -250,16 +265,18 @@ func creditTransactionFromLedgerTransaction(tx ledger.Transaction) (CreditTransa if err != nil { return CreditTransaction{}, err } + cursor := tx.Cursor() return CreditTransaction{ - ID: tx.ID(), - CreatedAt: tx.Cursor().CreatedAt, - BookedAt: tx.BookedAt(), - Type: creditTransactionType(fboImpact), - Currency: currency, - Amount: fboImpact, - Name: "", - Annotations: tx.Annotations(), + ID: tx.ID(), + CreatedAt: tx.Cursor().CreatedAt, + BookedAt: tx.BookedAt(), + Type: creditTransactionType(fboImpact), + Currency: currency, + Amount: fboImpact, + Name: "", + Annotations: tx.Annotations(), + balanceCursor: &cursor, }, nil } @@ -300,6 +317,19 @@ func applyCreditTransactionBalances(items []CreditTransaction, after alpacadecim } } +func (tx CreditTransaction) balanceQuery() ledger.BalanceQuery { + if tx.balanceCursor != nil { + return ledger.BalanceQuery{After: tx.balanceCursor} + } + + if tx.balanceAsOf != nil { + return ledger.BalanceQuery{AsOf: tx.balanceAsOf} + } + + asOf := tx.BookedAt + return ledger.BalanceQuery{AsOf: &asOf} +} + func creditTransactionType(fboImpact alpacadecimal.Decimal) CreditTransactionType { if fboImpact.IsPositive() { return CreditTransactionTypeFunded diff --git a/openmeter/ledger/historical/adapter/ledger.go b/openmeter/ledger/historical/adapter/ledger.go index 645bd1cc3c..5841edac5f 100644 --- a/openmeter/ledger/historical/adapter/ledger.go +++ b/openmeter/ledger/historical/adapter/ledger.go @@ -9,6 +9,7 @@ import ( sql "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqljson" "github.com/alpacahq/alpacadecimal" + "github.com/lib/pq" "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/ent/db" @@ -272,7 +273,8 @@ func (r *repo) SumEntries(ctx context.Context, query ledger.Query) (alpacadecima func (r *repo) ListTransactions(ctx context.Context, input ledger.ListTransactionsInput) (ledger.ListTransactionsResult, error) { return entutils.TransactingRepo(ctx, r, func(ctx context.Context, tx *repo) (ledger.ListTransactionsResult, error) { - entryPredicates := listTransactionsEntryPredicates(input.AccountIDs, input.Currency) + entryPredicates := listTransactionsEntryPredicates(input.AccountIDs, input.Currency, input.Route) + subAccountPredicates := listTransactionsSubAccountPredicates(input.AccountIDs, input.Currency, input.Route) query := tx.db.LedgerTransaction.Query(). Where(ledgertransactiondb.Namespace(input.Namespace)). @@ -298,25 +300,10 @@ func (r *repo) ListTransactions(ctx context.Context, input ledger.ListTransactio query = query.Where(ledgertransactiondb.BookedAtLTE(*input.AsOf)) } - // Scope to specific accounts. - if len(input.AccountIDs) > 0 { + if len(subAccountPredicates) > 0 { query = query.Where( ledgertransactiondb.HasEntriesWith( - ledgerentrydb.HasSubAccountWith( - ledgersubaccountdb.AccountIDIn(input.AccountIDs...), - ), - ), - ) - } - - if input.Currency != nil { - query = query.Where( - ledgertransactiondb.HasEntriesWith( - ledgerentrydb.HasSubAccountWith( - ledgersubaccountdb.HasRouteWith( - ledgersubaccountroutedb.Currency(string(*input.Currency)), - ), - ), + ledgerentrydb.HasSubAccountWith(subAccountPredicates...), ), ) } @@ -339,7 +326,7 @@ func (r *repo) ListTransactions(ctx context.Context, input ledger.ListTransactio } if input.CreditMovement != ledger.ListTransactionsCreditMovementUnspecified { - pred, err := ledgerTransactionCreditMovementPredicate(input.AccountIDs, input.Currency, input.CreditMovement) + pred, err := ledgerTransactionCreditMovementPredicate(input.AccountIDs, input.Currency, input.Route, input.CreditMovement) if err != nil { return ledger.ListTransactionsResult{}, err } @@ -402,27 +389,60 @@ func (r *repo) ListTransactions(ctx context.Context, input ledger.ListTransactio }) } -func listTransactionsEntryPredicates(accountIDs []string, currency *currencyx.Code) []predicate.LedgerEntry { +func listTransactionsEntryPredicates(accountIDs []string, currency *currencyx.Code, route ledger.RouteFilter) []predicate.LedgerEntry { entryPredicates := make([]predicate.LedgerEntry, 0, 2) + subAccountPredicates := listTransactionsSubAccountPredicates(accountIDs, currency, route) + + if len(subAccountPredicates) > 0 { + entryPredicates = append(entryPredicates, ledgerentrydb.HasSubAccountWith(subAccountPredicates...)) + } + + return entryPredicates +} + +func listTransactionsSubAccountPredicates(accountIDs []string, currency *currencyx.Code, route ledger.RouteFilter) []predicate.LedgerSubAccount { subAccountPredicates := make([]predicate.LedgerSubAccount, 0, 2) if len(accountIDs) > 0 { subAccountPredicates = append(subAccountPredicates, ledgersubaccountdb.AccountIDIn(accountIDs...)) } - if currency != nil { + routePredicates := listTransactionsRoutePredicates(currency, route) + if len(routePredicates) > 0 { subAccountPredicates = append(subAccountPredicates, - ledgersubaccountdb.HasRouteWith( - ledgersubaccountroutedb.Currency(string(*currency)), - ), + ledgersubaccountdb.HasRouteWith(routePredicates...), ) } - if len(subAccountPredicates) > 0 { - entryPredicates = append(entryPredicates, ledgerentrydb.HasSubAccountWith(subAccountPredicates...)) + return subAccountPredicates +} + +func listTransactionsRoutePredicates(currency *currencyx.Code, route ledger.RouteFilter) []predicate.LedgerSubAccountRoute { + routePredicates := make([]predicate.LedgerSubAccountRoute, 0, 3) + + if currency != nil { + routePredicates = append(routePredicates, ledgersubaccountroutedb.Currency(string(*currency))) } - return entryPredicates + if route.Currency != "" { + routePredicates = append(routePredicates, ledgersubaccountroutedb.Currency(string(route.Currency))) + } + + if route.Features.IsPresent() { + features, _ := route.Features.Get() + features = ledger.SortedFeatures(features) + if len(features) == 0 { + routePredicates = append(routePredicates, ledgersubaccountroutedb.FeaturesIsNil()) + } else { + routePredicates = append(routePredicates, ledgersubaccountroutedb.Features(pq.StringArray(features))) + } + } + + if route.MatchFeature != "" { + routePredicates = append(routePredicates, matchFeature(route.MatchFeature)) + } + + return routePredicates } func ledgerTransactionAfterCursorPredicate(cursor ledger.TransactionCursor) predicate.LedgerTransaction { @@ -477,6 +497,7 @@ func listTransactionsOrdering(before bool) []ledgertransactiondb.OrderOption { func ledgerTransactionCreditMovementPredicate( accountIDs []string, currency *currencyx.Code, + route ledger.RouteFilter, movement ledger.ListTransactionsCreditMovement, ) (predicate.LedgerTransaction, error) { var having *sql.Predicate @@ -495,7 +516,7 @@ func ledgerTransactionCreditMovementPredicate( return func(s *sql.Selector) { s.Where(sql.In( s.C(ledgertransactiondb.FieldID), - scopedFBOMovementTransactionSelector(accountIDs, currency, having), + scopedFBOMovementTransactionSelector(accountIDs, currency, route, having), )) }, nil } @@ -503,11 +524,14 @@ func ledgerTransactionCreditMovementPredicate( func scopedFBOMovementTransactionSelector( accountIDs []string, currency *currencyx.Code, + route ledger.RouteFilter, having *sql.Predicate, ) *sql.Selector { - entries := sql.Table(ledgerentrydb.Table) - subAccounts := sql.Table(ledgersubaccountdb.Table) - accounts := sql.Table(ledgeraccountdb.Table) + const routeTableAlias = "lsar" + + entries := sql.Table(ledgerentrydb.Table).As("le") + subAccounts := sql.Table(ledgersubaccountdb.Table).As("lsa") + accounts := sql.Table(ledgeraccountdb.Table).As("la") selector := sql.Select(entries.C(ledgerentrydb.FieldTransactionID)). From(entries). @@ -521,12 +545,15 @@ func scopedFBOMovementTransactionSelector( selector.Where(sql.In(subAccounts.C(ledgersubaccountdb.FieldAccountID), stringsToAny(accountIDs)...)) } - if currency != nil { - routes := sql.Table(ledgersubaccountroutedb.Table) + routes := sql.Table(ledgersubaccountroutedb.Table).As(routeTableAlias) + routePredicates := scopedRouteSelectorPredicates(currency, route, routes.C, routeTableAlias) + if len(routePredicates) > 0 { selector. Join(routes). - On(subAccounts.C(ledgersubaccountdb.FieldRouteID), routes.C(ledgersubaccountroutedb.FieldID)). - Where(sql.EQ(routes.C(ledgersubaccountroutedb.FieldCurrency), string(*currency))) + On(subAccounts.C(ledgersubaccountdb.FieldRouteID), routes.C(ledgersubaccountroutedb.FieldID)) + for _, predicate := range routePredicates { + selector.Where(predicate) + } } return selector. @@ -534,6 +561,80 @@ func scopedFBOMovementTransactionSelector( Having(having) } +func scopedRouteSelectorPredicates(currency *currencyx.Code, route ledger.RouteFilter, routeColumn func(string) string, routeTableAlias string) []*sql.Predicate { + predicates := make([]*sql.Predicate, 0, 3) + + if currency != nil { + predicates = append(predicates, sql.EQ(routeColumn(ledgersubaccountroutedb.FieldCurrency), string(*currency))) + } + + if route.Currency != "" { + predicates = append(predicates, sql.EQ(routeColumn(ledgersubaccountroutedb.FieldCurrency), string(route.Currency))) + } + + if route.Features.IsPresent() { + features, _ := route.Features.Get() + features = ledger.SortedFeatures(features) + if len(features) == 0 { + predicates = append(predicates, sql.IsNull(routeColumn(ledgersubaccountroutedb.FieldFeatures))) + } else { + predicates = append(predicates, postgresArrayRouteExpression{ + Column: postgresQualifiedColumn{ + TableAlias: routeTableAlias, + Field: ledgersubaccountroutedb.FieldFeatures, + }, + Operator: postgresArrayRouteOperatorEqual, + Value: pq.StringArray(features), + }.Predicate()) + } + } + + if route.MatchFeature != "" { + predicates = append(predicates, sql.Or( + sql.IsNull(routeColumn(ledgersubaccountroutedb.FieldFeatures)), + postgresArrayRouteExpression{ + Column: postgresQualifiedColumn{ + TableAlias: routeTableAlias, + Field: ledgersubaccountroutedb.FieldFeatures, + }, + Operator: postgresArrayRouteOperatorContains, + Value: pq.StringArray{route.MatchFeature}, + }.Predicate(), + )) + } + + return predicates +} + +type postgresArrayRouteOperator string + +const ( + postgresArrayRouteOperatorEqual postgresArrayRouteOperator = "=" + postgresArrayRouteOperatorContains postgresArrayRouteOperator = "@>" +) + +type postgresQualifiedColumn struct { + TableAlias string + Field string +} + +func (c postgresQualifiedColumn) Ident(b *sql.Builder) { + b.Ident(c.TableAlias).WriteString(".").Ident(c.Field) +} + +type postgresArrayRouteExpression struct { + Column postgresQualifiedColumn + Operator postgresArrayRouteOperator + Value pq.StringArray +} + +func (e postgresArrayRouteExpression) Predicate() *sql.Predicate { + return sql.P(func(b *sql.Builder) { + e.Column.Ident(b) + b.WriteString(" ").WriteString(string(e.Operator)).WriteString(" ").Arg(e.Value) + }) +} + func scopedEntryAmountSumPredicate(op string) *sql.Predicate { return sql.ExprP(fmt.Sprintf("SUM(%s) %s 0", ledgerentrydb.FieldAmount, op)) } diff --git a/openmeter/ledger/historical/adapter/ledger_test.go b/openmeter/ledger/historical/adapter/ledger_test.go index 1f763e598b..35a56bb883 100644 --- a/openmeter/ledger/historical/adapter/ledger_test.go +++ b/openmeter/ledger/historical/adapter/ledger_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/alpacahq/alpacadecimal" + "github.com/lib/pq" "github.com/samber/lo" "github.com/samber/mo" "github.com/stretchr/testify/require" @@ -572,6 +573,97 @@ func TestRepo_ListTransactions_FiltersCreditMovementByScopedNetFBOAmount(t *test require.Len(t, funded.Items, 0) } +func TestRepo_ListTransactions_FiltersCreditMovementByMatchFeatureRoute(t *testing.T) { + env := NewTestEnv(t) + t.Cleanup(func() { + env.Close(t) + }) + env.DBSchemaMigrate(t) + + ctx := t.Context() + namespace := testNamespace() + unrestricted := env.createSubAccount(t, namespace, ledger.Route{Currency: currencyx.Code("USD")}) + featureA := env.createSubAccount(t, namespace, ledger.Route{Currency: currencyx.Code("USD"), Features: []string{"feature-a"}}) + featureAOrB := env.createSubAccount(t, namespace, ledger.Route{Currency: currencyx.Code("USD"), Features: []string{"feature-a", "feature-b"}}) + featureB := env.createSubAccount(t, namespace, ledger.Route{Currency: currencyx.Code("USD"), Features: []string{"feature-b"}}) + + group, err := env.repo.CreateTransactionGroup(ctx, ledgerhistorical.CreateTransactionGroupInput{ + Namespace: namespace, + }) + require.NoError(t, err) + + tx, err := env.repo.BookTransaction(ctx, models.NamespacedID{Namespace: namespace, ID: group.ID}, mustSetUpHistoricalTransactionInput(t, time.Now().UTC(), []*transactionstestutils.AnyEntryInput{ + { + Address: testAddress(t, unrestricted), + AmountValue: alpacadecimal.NewFromInt(100), + }, + { + Address: testAddress(t, featureA), + AmountValue: alpacadecimal.NewFromInt(10), + }, + { + Address: testAddress(t, featureAOrB), + AmountValue: alpacadecimal.NewFromInt(20), + }, + { + Address: testAddress(t, featureB), + AmountValue: alpacadecimal.NewFromInt(-130), + }, + })) + require.NoError(t, err) + + accountIDs := []string{ + unrestricted.AccountID, + featureA.AccountID, + featureAOrB.AccountID, + featureB.AccountID, + } + + featureAPositive, err := env.repo.ListTransactions(ctx, ledger.ListTransactionsInput{ + Namespace: namespace, + Limit: 20, + AccountIDs: accountIDs, + Route: ledger.RouteFilter{MatchFeature: "feature-a"}, + CreditMovement: ledger.ListTransactionsCreditMovementPositive, + }) + require.NoError(t, err) + require.Len(t, featureAPositive.Items, 1) + require.Equal(t, tx.ID(), featureAPositive.Items[0].ID()) + require.ElementsMatch(t, + []string{unrestricted.ID, featureA.ID, featureAOrB.ID}, + lo.Map(featureAPositive.Items[0].Entries(), func(entry ledger.Entry, _ int) string { + return entry.PostingAddress().SubAccountID() + }), + ) + + featureBPositive, err := env.repo.ListTransactions(ctx, ledger.ListTransactionsInput{ + Namespace: namespace, + Limit: 20, + AccountIDs: accountIDs, + Route: ledger.RouteFilter{MatchFeature: "feature-b"}, + CreditMovement: ledger.ListTransactionsCreditMovementPositive, + }) + require.NoError(t, err) + require.Len(t, featureBPositive.Items, 0) + + featureBNegative, err := env.repo.ListTransactions(ctx, ledger.ListTransactionsInput{ + Namespace: namespace, + Limit: 20, + AccountIDs: accountIDs, + Route: ledger.RouteFilter{MatchFeature: "feature-b"}, + CreditMovement: ledger.ListTransactionsCreditMovementNegative, + }) + require.NoError(t, err) + require.Len(t, featureBNegative.Items, 1) + require.Equal(t, tx.ID(), featureBNegative.Items[0].ID()) + require.ElementsMatch(t, + []string{unrestricted.ID, featureAOrB.ID, featureB.ID}, + lo.Map(featureBNegative.Items[0].Entries(), func(entry ledger.Entry, _ int) string { + return entry.PostingAddress().SubAccountID() + }), + ) +} + func TestRepo_ListTransactions_PaginatesAndFiltersByAccountAndAnnotation(t *testing.T) { env := NewTestEnv(t) t.Cleanup(func() { @@ -994,6 +1086,77 @@ func TestRepo_SumEntries_Filters(t *testing.T) { require.True(t, sumPartialFeatures.Equal(alpacadecimal.Zero)) } +func TestRepo_SumEntries_MatchFeature(t *testing.T) { + env := NewTestEnv(t) + t.Cleanup(func() { + env.Close(t) + }) + env.DBSchemaMigrate(t) + + ctx := t.Context() + namespace := testNamespace() + unrestricted := env.createSubAccount(t, namespace, ledger.Route{Currency: currencyx.Code("USD")}) + featureA := env.createSubAccount(t, namespace, ledger.Route{Currency: currencyx.Code("USD"), Features: []string{"feature-a"}}) + featureAOrB := env.createSubAccount(t, namespace, ledger.Route{Currency: currencyx.Code("USD"), Features: []string{"feature-a", "feature-b"}}) + + group, err := env.repo.CreateTransactionGroup(ctx, ledgerhistorical.CreateTransactionGroupInput{ + Namespace: namespace, + }) + require.NoError(t, err) + + _, err = env.repo.BookTransaction(ctx, models.NamespacedID{Namespace: namespace, ID: group.ID}, mustSetUpHistoricalTransactionInput(t, time.Now().UTC(), []*transactionstestutils.AnyEntryInput{ + { + Address: testAddress(t, unrestricted), + AmountValue: alpacadecimal.NewFromInt(100), + }, + { + Address: testAddress(t, featureA), + AmountValue: alpacadecimal.NewFromInt(10), + }, + { + Address: testAddress(t, featureAOrB), + AmountValue: alpacadecimal.NewFromInt(10), + }, + })) + require.NoError(t, err) + + sumFeatureA, err := env.repo.SumEntries(ctx, ledger.Query{ + Namespace: namespace, + Filters: ledger.Filters{ + Route: ledger.RouteFilter{ + Currency: currencyx.Code("USD"), + MatchFeature: "feature-a", + }, + }, + }) + require.NoError(t, err) + require.True(t, sumFeatureA.Equal(alpacadecimal.NewFromInt(120))) + + sumFeatureB, err := env.repo.SumEntries(ctx, ledger.Query{ + Namespace: namespace, + Filters: ledger.Filters{ + Route: ledger.RouteFilter{ + Currency: currencyx.Code("USD"), + MatchFeature: "feature-b", + }, + }, + }) + require.NoError(t, err) + require.True(t, sumFeatureB.Equal(alpacadecimal.NewFromInt(110))) + + sumUnknownFeature, err := env.repo.SumEntries(ctx, ledger.Query{ + Namespace: namespace, + Filters: ledger.Filters{ + Route: ledger.RouteFilter{ + Currency: currencyx.Code("USD"), + MatchFeature: "feature-c", + }, + }, + }) + require.NoError(t, err) + require.True(t, sumUnknownFeature.Equal(alpacadecimal.NewFromInt(100))) +} + func TestSumEntriesQuery_SQL(t *testing.T) { bookedFrom := time.Now().UTC().Add(-1 * time.Hour) txID := "01TESTTXID1234567890123456" @@ -1029,6 +1192,31 @@ func TestSumEntriesQuery_SQL(t *testing.T) { }, args) } +func TestSumEntriesQuery_SQLMatchFeature(t *testing.T) { + q := sumEntriesQuery{ + query: ledger.Query{ + Namespace: "ns-test", + Filters: ledger.Filters{ + Route: ledger.RouteFilter{ + Currency: currencyx.Code("USD"), + MatchFeature: "api-calls", + }, + }, + }, + } + + sqlStr, args, err := q.SQL() + require.NoError(t, err) + + require.Contains(t, sqlStr, `"ledger_sub_account_routes"."features" IS NULL`) + require.Contains(t, sqlStr, `"ledger_sub_account_routes"."features" @> $3`) + require.Equal(t, []any{ + "ns-test", + "USD", + pq.StringArray{"api-calls"}, + }, args) +} + // ---------------------------------------------------------------------------- // Test helpers // ---------------------------------------------------------------------------- diff --git a/openmeter/ledger/historical/adapter/sumentries_query.go b/openmeter/ledger/historical/adapter/sumentries_query.go index 3f19c8ac9f..19da69ff7d 100644 --- a/openmeter/ledger/historical/adapter/sumentries_query.go +++ b/openmeter/ledger/historical/adapter/sumentries_query.go @@ -143,6 +143,9 @@ func (b *sumEntriesQuery) subAccountPredicates() ([]predicate.LedgerSubAccount, routePredicates = append(routePredicates, ledgersubaccountroutedb.Features(pq.StringArray(features))) } } + if normalizedRoute.MatchFeature != "" { + routePredicates = append(routePredicates, matchFeature(normalizedRoute.MatchFeature)) + } if normalizedRoute.CostBasis.IsPresent() { costBasis, _ := normalizedRoute.CostBasis.Get() if costBasis != nil { @@ -169,3 +172,14 @@ func (b *sumEntriesQuery) subAccountPredicates() ([]predicate.LedgerSubAccount, return subAccountPredicates, nil } + +func matchFeature(feature string) predicate.LedgerSubAccountRoute { + return func(s *sql.Selector) { + s.Where(sql.Or( + sql.IsNull(s.C(ledgersubaccountroutedb.FieldFeatures)), + sql.P(func(b *sql.Builder) { + b.Ident(s.C(ledgersubaccountroutedb.FieldFeatures)).WriteString(" @> ").Arg(pq.StringArray{feature}) + }), + )) + } +} diff --git a/openmeter/ledger/primitives.go b/openmeter/ledger/primitives.go index 4018b95c9c..a84fa73fdf 100644 --- a/openmeter/ledger/primitives.go +++ b/openmeter/ledger/primitives.go @@ -52,8 +52,11 @@ type RouteFilter struct { // Non-currency fields are retained for near-future expansion. TaxCode mo.Option[*string] TaxBehavior mo.Option[*TaxBehavior] - Features mo.Option[[]string] - CostBasis mo.Option[*alpacadecimal.Decimal] + // Features is an exact-match filter for sub-account routes. + Features mo.Option[[]string] + // MatchFeature matches unrestricted routes and routes containing this single feature key. + MatchFeature string + CostBasis mo.Option[*alpacadecimal.Decimal] // CreditPriority is only meaningful for customer_fbo queries. CreditPriority *int @@ -194,6 +197,7 @@ type ListTransactionsInput struct { AccountIDs []string Currency *currencyx.Code AsOf *time.Time + Route RouteFilter CreditMovement ListTransactionsCreditMovement @@ -270,6 +274,14 @@ func (i ListTransactionsInput) Validate() error { }) } + if err := validateListTransactionsRouteFilter(i.Route); err != nil { + return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{ + "reason": "route_invalid", + "route": i.Route, + "error": err, + }) + } + switch i.CreditMovement { case ListTransactionsCreditMovementUnspecified, ListTransactionsCreditMovementPositive, ListTransactionsCreditMovementNegative: default: @@ -282,6 +294,49 @@ func (i ListTransactionsInput) Validate() error { return nil } +func validateListTransactionsRouteFilter(route RouteFilter) error { + var errs []error + + if route.TaxCode.IsPresent() { + errs = append(errs, errors.New("tax code filter is not supported")) + } + + if route.TaxBehavior.IsPresent() { + errs = append(errs, errors.New("tax behavior filter is not supported")) + } + + if route.CostBasis.IsPresent() { + errs = append(errs, errors.New("cost basis filter is not supported")) + } + + if route.CreditPriority != nil { + errs = append(errs, errors.New("credit priority filter is not supported")) + } + + if route.TransactionAuthorizationStatus != nil { + errs = append(errs, errors.New("transaction authorization status filter is not supported")) + } + + if route.Features.IsPresent() && route.MatchFeature != "" { + errs = append(errs, errors.New("features and match feature filters cannot be combined")) + } + + if route.Features.IsPresent() { + features, _ := route.Features.Get() + if err := validateFeatures(features); err != nil { + errs = append(errs, fmt.Errorf("features: %w", err)) + } + } + + if route.MatchFeature != "" { + if err := validateFeatures([]string{route.MatchFeature}); err != nil { + errs = append(errs, fmt.Errorf("match feature: %w", err)) + } + } + + return errors.Join(errs...) +} + type ListTransactionsCreditMovement uint8 const ( diff --git a/openmeter/ledger/routing.go b/openmeter/ledger/routing.go index a6aa6ac6b2..75ea30b5a7 100644 --- a/openmeter/ledger/routing.go +++ b/openmeter/ledger/routing.go @@ -252,6 +252,11 @@ func (r Route) Matches(filter RouteFilter) bool { return false } } + if filter.MatchFeature != "" { + if len(r.Features) > 0 && !slices.Contains(r.Features, filter.MatchFeature) { + return false + } + } if filter.CostBasis.IsPresent() { costBasis, _ := filter.CostBasis.Get() switch { @@ -290,9 +295,17 @@ func (r Route) Normalize() (Route, error) { // Normalize canonicalizes route filter values before querying. func (f RouteFilter) Normalize() (RouteFilter, error) { - if f.Currency == "" && f.TaxCode.IsAbsent() && f.Features.IsAbsent() && f.CostBasis.IsAbsent() && f.CreditPriority == nil && f.TransactionAuthorizationStatus == nil && f.TaxBehavior.IsAbsent() { + if f.Currency == "" && f.TaxCode.IsAbsent() && f.Features.IsAbsent() && f.MatchFeature == "" && f.CostBasis.IsAbsent() && f.CreditPriority == nil && f.TransactionAuthorizationStatus == nil && f.TaxBehavior.IsAbsent() { return f, nil } + if f.Features.IsPresent() && f.MatchFeature != "" { + return RouteFilter{}, errors.New("features and match feature filters cannot be combined") + } + if f.MatchFeature != "" { + if err := validateFeatures([]string{f.MatchFeature}); err != nil { + return RouteFilter{}, fmt.Errorf("match feature: %w", err) + } + } taxCode, _ := f.TaxCode.Get() taxBehavior, _ := f.TaxBehavior.Get() @@ -336,6 +349,7 @@ func (f RouteFilter) Normalize() (RouteFilter, error) { TaxCode: normalizedTaxCode, TaxBehavior: normalizedTaxBehavior, Features: normalizedFeatures, + MatchFeature: f.MatchFeature, CostBasis: normalizedCostBasis, CreditPriority: normalized.CreditPriority, TransactionAuthorizationStatus: normalized.TransactionAuthorizationStatus, @@ -509,6 +523,11 @@ func SortedFeatures(features []string) []string { return sorted } +// ValidateFeatures validates feature route keys. +func ValidateFeatures(features []string) error { + return validateFeatures(features) +} + func validateFeatures(features []string) error { var errs []error seen := make(map[string]struct{}, len(features)) diff --git a/openmeter/ledger/routing_test.go b/openmeter/ledger/routing_test.go index 6a71a838ef..3e63112be9 100644 --- a/openmeter/ledger/routing_test.go +++ b/openmeter/ledger/routing_test.go @@ -400,6 +400,24 @@ func TestRouteMatches(t *testing.T) { filter: RouteFilter{Features: mo.Some([]string{"api-calls"})}, want: false, }, + { + name: "match feature matches populated route containing feature", + route: route, + filter: RouteFilter{MatchFeature: "api-calls"}, + want: true, + }, + { + name: "match feature matches unrestricted route", + route: unrestrictedRoute, + filter: RouteFilter{MatchFeature: "api-calls"}, + want: true, + }, + { + name: "match feature rejects populated route without feature", + route: route, + filter: RouteFilter{MatchFeature: "compute"}, + want: false, + }, { name: "cost basis absent ignores populated route cost basis", route: route, @@ -533,6 +551,14 @@ func TestRouteMatches(t *testing.T) { } } +func TestRouteFilter_NormalizeRejectsExactAndMatchFeatures(t *testing.T) { + _, err := RouteFilter{ + Features: mo.Some([]string{"api-calls"}), + MatchFeature: "api-calls", + }.Normalize() + require.Error(t, err) +} + func mustDecimal(t *testing.T, raw string) alpacadecimal.Decimal { t.Helper() diff --git a/openmeter/ledger/validations_test.go b/openmeter/ledger/validations_test.go index 064e717348..03b4caa246 100644 --- a/openmeter/ledger/validations_test.go +++ b/openmeter/ledger/validations_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/alpacahq/alpacadecimal" + "github.com/samber/mo" "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/openmeter/ledger" @@ -91,6 +92,100 @@ func TestValidateTransactionInputEntryAmountPrecision(t *testing.T) { } } +func TestListTransactionsInputValidateRouteFilter(t *testing.T) { + costBasis := alpacadecimal.NewFromFloat(0.7) + taxCode := "vat" + taxBehavior := ledger.TaxBehaviorInclusive + creditPriority := 1 + authStatus := ledger.TransactionAuthorizationStatusAuthorized + + tests := []struct { + name string + route ledger.RouteFilter + wantErr bool + }{ + { + name: "currency route filter is supported", + route: ledger.RouteFilter{ + Currency: currencyx.Code("USD"), + }, + }, + { + name: "exact features route filter is supported", + route: ledger.RouteFilter{ + Features: mo.Some([]string{"feature-a"}), + }, + }, + { + name: "match feature route filter is supported", + route: ledger.RouteFilter{ + MatchFeature: "feature-a", + }, + }, + { + name: "cost basis route filter is rejected", + route: ledger.RouteFilter{ + CostBasis: mo.Some(&costBasis), + }, + wantErr: true, + }, + { + name: "tax code route filter is rejected", + route: ledger.RouteFilter{ + TaxCode: mo.Some(&taxCode), + }, + wantErr: true, + }, + { + name: "tax behavior route filter is rejected", + route: ledger.RouteFilter{ + TaxBehavior: mo.Some(&taxBehavior), + }, + wantErr: true, + }, + { + name: "credit priority route filter is rejected", + route: ledger.RouteFilter{ + CreditPriority: &creditPriority, + }, + wantErr: true, + }, + { + name: "transaction authorization route filter is rejected", + route: ledger.RouteFilter{ + TransactionAuthorizationStatus: &authStatus, + }, + wantErr: true, + }, + { + name: "exact features and match feature cannot be combined", + route: ledger.RouteFilter{ + Features: mo.Some([]string{"feature-a"}), + MatchFeature: "feature-a", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ledger.ListTransactionsInput{ + Namespace: "ns-test", + Limit: 1, + Route: tt.route, + }.Validate() + + if tt.wantErr { + require.Error(t, err) + require.ErrorIs(t, err, ledger.ErrListTransactionsInputInvalid) + return + } + + require.NoError(t, err) + }) + } +} + func mustPostingAddress(t *testing.T, currency currencyx.Code) ledger.PostingAddress { t.Helper() diff --git a/test/credits/creditgrant_test.go b/test/credits/creditgrant_test.go index d245c63116..ba7d5069dd 100644 --- a/test/credits/creditgrant_test.go +++ b/test/credits/creditgrant_test.go @@ -31,7 +31,6 @@ import ( omtestutils "github.com/openmeterio/openmeter/openmeter/testutils" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/datetime" - "github.com/openmeterio/openmeter/pkg/models" billingtest "github.com/openmeterio/openmeter/test/billing" ) @@ -190,12 +189,14 @@ func (s *CreditGrantTestSuite) TestCreatePromotionalGrant() { s.Len(gatheringInvoices.Items, 0) } -func (s *CreditGrantTestSuite) TestCreateFeatureFilteredGrantUnsupported() { +func (s *CreditGrantTestSuite) TestCreateFeatureFilteredGrant() { ctx := context.Background() - ns := s.GetUniqueNamespace("creditgrant-service-feature-filters-unsupported") + ns := s.GetUniqueNamespace("creditgrant-service-feature-filters") + s.ProvisionDefaultTaxCodes(ctx, ns) + cust := s.CreateLedgerBackedCustomer(ns, "test-subject") - _, err := s.CreditGrantService.Create(ctx, creditgrant.CreateInput{ + grant, err := s.CreditGrantService.Create(ctx, creditgrant.CreateInput{ Namespace: ns, CustomerID: cust.ID, Name: "feature filtered grant", @@ -206,13 +207,12 @@ func (s *CreditGrantTestSuite) TestCreateFeatureFilteredGrantUnsupported() { Features: []string{"api-calls"}, }, }) - s.Require().Error(err) - s.ErrorContains(err, "credit grant feature filters are not supported yet") - - issues, err := models.AsValidationIssues(err) s.Require().NoError(err) - s.Require().Len(issues, 1) - s.Equal(creditgrant.ErrCodeCreditGrantFeatureFiltersUnsupported, issues[0].Code()) + + s.Equal(creditpurchase.SettlementTypePromotional, grant.Intent.Settlement.Type()) + s.Equal(creditpurchase.StatusFinal, grant.Status) + s.Equal(creditpurchase.FeatureFilters{"api-calls"}, grant.Intent.FeatureFilters) + s.NotNil(grant.Realizations.CreditGrantRealization) } func (s *CreditGrantTestSuite) TestCreateExternalGrantAndSettle() {