diff --git a/openmeter/entitlement/snapshot/event.go b/openmeter/entitlement/snapshot/event.go index 1a0703fdb6..af48d314bb 100644 --- a/openmeter/entitlement/snapshot/event.go +++ b/openmeter/entitlement/snapshot/event.go @@ -13,6 +13,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "github.com/openmeterio/openmeter/openmeter/subject" "github.com/openmeterio/openmeter/openmeter/watermill/marshaler" + pkgmodels "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/timeutil" ) @@ -39,6 +40,8 @@ func (o ValueOperationType) Validate() error { return nil } +var _ pkgmodels.CustomValidator[EntitlementValue] = (*EntitlementValue)(nil) + type EntitlementValue struct { // Balance Only available for metered entitlements. Metered entitlements are built around a balance calculation where feature usage is deducted from the issued grants. Balance represents the remaining balance of the entitlement, it's value never turns negative. Balance *float64 `json:"balance,omitempty"` @@ -59,6 +62,10 @@ type EntitlementValue struct { Usage *float64 `json:"usage,omitempty"` } +func (e EntitlementValue) ValidateWith(validators ...pkgmodels.ValidatorFunc[EntitlementValue]) error { + return pkgmodels.Validate(e, validators...) +} + type SnapshotEvent struct { Entitlement entitlement.Entitlement `json:"entitlement"` Namespace models.NamespaceID `json:"namespace"` diff --git a/openmeter/notification/consumer/entitlementbalancethreshold.go b/openmeter/notification/consumer/entitlementbalancethreshold.go index beacad748e..0957f6cb47 100644 --- a/openmeter/notification/consumer/entitlementbalancethreshold.go +++ b/openmeter/notification/consumer/entitlementbalancethreshold.go @@ -29,11 +29,7 @@ import ( "github.com/openmeterio/openmeter/pkg/timeutil" ) -type EntitlementSnapshotHandlerState struct { - TotalGrants float64 `json:"totalGrants"` -} - -var ErrNoBalanceAvailable = errors.New("no balance available") +var ErrNoBalanceAvailable = errors.New("no balance available for entitlement") func (b *EntitlementSnapshotHandler) handleAsSnapshotEvent(ctx context.Context, event snapshot.SnapshotEvent) error { // TODO[issue-1364]: this must be cached to prevent going to the DB for each balance.snapshot event @@ -146,7 +142,7 @@ func (b *EntitlementSnapshotHandler) handleRule(ctx context.Context, balSnapshot lastEventActualValue, err := getNumericThreshold( lastEvent.Payload.BalanceThreshold.Threshold, - lastEvent.Payload.BalanceThreshold.Value) + snapshot.EntitlementValue(lastEvent.Payload.BalanceThreshold.Value)) if err != nil { if errors.Is(err, ErrNoBalanceAvailable) { // In case there are no grants, percentage all percentage rules would match, so let's instead @@ -392,42 +388,48 @@ type numericThreshold struct { const absoluteZero = 1e-9 -func getNumericThreshold(threshold notification.BalanceThreshold, value api.EntitlementValue) (*numericThreshold, error) { +func getNumericThreshold(threshold notification.BalanceThreshold, value snapshot.EntitlementValue) (*numericThreshold, error) { var ( - balance = lo.FromPtr(value.Balance) - usage = lo.FromPtr(value.Usage) - overage = lo.FromPtr(value.Overage) - totalGrants = lo.FromPtr(value.TotalAvailableGrantAmount) + // Balance = TotalAvailableGrants - Usage + balance = lo.FromPtr(value.Balance) + // Usage = TotalAvailableGrants - Balance. It is only available if active grants are available in the current usage period. + usage = lo.FromPtr(value.Usage) + // Overage means the usage which is not covered by the grants. + overage = lo.FromPtr(value.Overage) + // TotalAvailableGrants means all the active grants available for in the current usage period. + totalAvailableGrants = lo.FromPtr(value.TotalAvailableGrantAmount) + // Total usage is the sum of the usage and overage + totalUsage = usage + overage ) - // If no grants are available, we cannot calculate the threshold value. - if totalGrants == 0 { - return nil, ErrNoBalanceAvailable - } - - // Invalid entitlement value as there cannot be overage if the balance is not zero. - if balance > absoluteZero && overage > absoluteZero { - return nil, errors.New("balance and overage cannot be positive number at the same time") - } - switch threshold.Type { // Deprecated: obsoleted by api.NotificationRuleBalanceThresholdValueTypeUsageValue case api.NotificationRuleBalanceThresholdValueTypeUsageValue, api.NotificationRuleBalanceThresholdValueTypeNumber: return &numericThreshold{ BalanceThreshold: threshold, ThresholdValue: threshold.Value, - Active: threshold.Value < usage, + Active: threshold.Value < totalUsage, }, nil // Deprecated: obsoleted by api.NotificationRuleBalanceThresholdValueTypeUsagePercentage case api.NotificationRuleBalanceThresholdValueTypeUsagePercentage, api.NotificationRuleBalanceThresholdValueTypePercent: - thresholdValue := totalGrants * (threshold.Value / 100) + // If no grants are available, we cannot calculate the threshold value. + if totalAvailableGrants <= absoluteZero { + return nil, ErrNoBalanceAvailable + } + + thresholdValue := totalAvailableGrants * (threshold.Value / 100) return &numericThreshold{ BalanceThreshold: threshold, ThresholdValue: thresholdValue, - Active: thresholdValue < usage, + Active: thresholdValue < totalUsage, }, nil case api.NotificationRuleBalanceThresholdValueTypeBalanceValue: + // If no grants are available, we cannot calculate the threshold value. + if totalAvailableGrants <= absoluteZero { + return nil, ErrNoBalanceAvailable + } + active := threshold.Value > balance if threshold.Value == 0 { @@ -440,7 +442,7 @@ func getNumericThreshold(threshold notification.BalanceThreshold, value api.Enti Active: active, }, nil default: - return nil, errors.New("unknown threshold type") + return nil, fmt.Errorf("unknown threshold type: %s", threshold.Type) } } @@ -468,7 +470,7 @@ func getActiveThresholdsWithHighestPriority(thresholds []notification.BalanceThr ) for _, threshold := range thresholds { - numThreshold, err := getNumericThreshold(threshold, api.EntitlementValue(value)) + numThreshold, err := getNumericThreshold(threshold, value) if err != nil { if errors.Is(err, ErrNoBalanceAvailable) { continue diff --git a/openmeter/notification/consumer/entitlementbalancethreshold_test.go b/openmeter/notification/consumer/entitlementbalancethreshold_test.go index d78792588b..89cb177415 100644 --- a/openmeter/notification/consumer/entitlementbalancethreshold_test.go +++ b/openmeter/notification/consumer/entitlementbalancethreshold_test.go @@ -1,11 +1,14 @@ package consumer import ( + "errors" + "fmt" "testing" "time" "github.com/samber/lo" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/api" "github.com/openmeterio/openmeter/openmeter/entitlement" @@ -53,12 +56,42 @@ func newBalanceValueThreshold(v float64) notification.BalanceThreshold { } } +var validateEntitlementValue = func(t *testing.T, value snapshot.EntitlementValue) { + t.Helper() + + var ( + balance = lo.FromPtr(value.Balance) + usage = lo.FromPtr(value.Usage) + overage = lo.FromPtr(value.Overage) + totalAvailableGrants = lo.FromPtr(value.TotalAvailableGrantAmount) + ) + + var errs []error + + if balance+usage != totalAvailableGrants { + errs = append(errs, fmt.Errorf("balance + usage != totalAvailableGrants: %v != %v", balance+usage, totalAvailableGrants)) + } + + if overage > 0 && usage < totalAvailableGrants { + errs = append(errs, fmt.Errorf("overage > 0 && usage < totalAvailableGrants: %v > 0 && %v < %v", overage, usage, totalAvailableGrants)) + } + + if overage > 0 && balance > 0 { + errs = append(errs, fmt.Errorf("overage > 0 && balance > 0: %v > 0 && %v > 0", overage, balance)) + } + + if err := errors.Join(errs...); err != nil { + require.NoErrorf(t, err, "invalid entitlement value: %v", errs) + } +} + func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { tests := []struct { Name string BalanceThresholds []notification.BalanceThreshold EntitlementValue snapshot.EntitlementValue Expected *activeThresholds + ExpectedErr error }{ // Usage value and percentage thresholds { @@ -95,12 +128,42 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { Balance: nil, }, }, + { + Name: "Usage values only - total usage over the total available grant amount", + BalanceThresholds: []notification.BalanceThreshold{ + newUsageValueThreshold(50), + }, + EntitlementValue: snapshot.EntitlementValue{ + Balance: lo.ToPtr(0.0), + Usage: lo.ToPtr(50.0), + Overage: lo.ToPtr(50.0), + TotalAvailableGrantAmount: lo.ToPtr(50.0), + }, + Expected: &activeThresholds{ + Usage: lo.ToPtr(newUsageValueThreshold(50)), + Balance: nil, + }, + }, + { + Name: "Usage values only - with overage over the total available grant amount", + BalanceThresholds: []notification.BalanceThreshold{ + newUsageValueThreshold(100), + }, + EntitlementValue: snapshot.EntitlementValue{ + Balance: lo.ToPtr(0.0), + Usage: lo.ToPtr(50.0), + Overage: lo.ToPtr(100.0), + TotalAvailableGrantAmount: lo.ToPtr(50.0), + }, + Expected: &activeThresholds{ + Usage: lo.ToPtr(newUsageValueThreshold(100)), + Balance: nil, + }, + }, { Name: "Number values only - 100% (deprecated)", BalanceThresholds: []notification.BalanceThreshold{ - newNumberThreshold(20), - newNumberThreshold(10), - newNumberThreshold(30), + newNumberThreshold(35), }, EntitlementValue: snapshot.EntitlementValue{ Balance: lo.ToPtr(0.0), @@ -108,16 +171,14 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { TotalAvailableGrantAmount: lo.ToPtr(35.0), }, Expected: &activeThresholds{ - Usage: lo.ToPtr(newNumberThreshold(30)), + Usage: nil, Balance: nil, }, }, { Name: "Usage values only - 100%", BalanceThresholds: []notification.BalanceThreshold{ - newUsageValueThreshold(20), - newUsageValueThreshold(10), - newUsageValueThreshold(30), + newUsageValueThreshold(35), }, EntitlementValue: snapshot.EntitlementValue{ Balance: lo.ToPtr(0.0), @@ -125,7 +186,7 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { TotalAvailableGrantAmount: lo.ToPtr(35.0), }, Expected: &activeThresholds{ - Usage: lo.ToPtr(newUsageValueThreshold(30)), + Usage: nil, Balance: nil, }, }, @@ -134,16 +195,16 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { BalanceThresholds: []notification.BalanceThreshold{ newNumberThreshold(20), newNumberThreshold(10), - newNumberThreshold(30), + newNumberThreshold(40), }, EntitlementValue: snapshot.EntitlementValue{ Balance: lo.ToPtr(0.0), Usage: lo.ToPtr(35.0), Overage: lo.ToPtr(10.0), - TotalAvailableGrantAmount: lo.ToPtr(25.0), + TotalAvailableGrantAmount: lo.ToPtr(35.0), }, Expected: &activeThresholds{ - Usage: lo.ToPtr(newNumberThreshold(30)), + Usage: lo.ToPtr(newNumberThreshold(40)), Balance: nil, }, }, @@ -152,16 +213,34 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { BalanceThresholds: []notification.BalanceThreshold{ newUsageValueThreshold(20), newUsageValueThreshold(10), - newUsageValueThreshold(30), + newUsageValueThreshold(40), }, EntitlementValue: snapshot.EntitlementValue{ Balance: lo.ToPtr(0.0), Usage: lo.ToPtr(35.0), Overage: lo.ToPtr(10.0), - TotalAvailableGrantAmount: lo.ToPtr(25.0), + TotalAvailableGrantAmount: lo.ToPtr(35.0), }, Expected: &activeThresholds{ - Usage: lo.ToPtr(newUsageValueThreshold(30)), + Usage: lo.ToPtr(newUsageValueThreshold(40)), + Balance: nil, + }, + }, + { + Name: "Usage values only - no grant with overage", + BalanceThresholds: []notification.BalanceThreshold{ + newUsageValueThreshold(50), + newUsageValueThreshold(100), + newUsageValueThreshold(120), + }, + EntitlementValue: snapshot.EntitlementValue{ + Balance: lo.ToPtr(0.0), + Usage: lo.ToPtr(0.0), + Overage: lo.ToPtr(110.0), + TotalAvailableGrantAmount: lo.ToPtr(0.0), + }, + Expected: &activeThresholds{ + Usage: lo.ToPtr(newUsageValueThreshold(100)), Balance: nil, }, }, @@ -175,7 +254,7 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { }, EntitlementValue: snapshot.EntitlementValue{ Balance: lo.ToPtr(0.0), - Usage: lo.ToPtr(110.0), + Usage: lo.ToPtr(95.0), Overage: lo.ToPtr(15.0), TotalAvailableGrantAmount: lo.ToPtr(95.0), }, @@ -198,7 +277,7 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { }, EntitlementValue: snapshot.EntitlementValue{ Balance: lo.ToPtr(0.0), - Usage: lo.ToPtr(110.0), + Usage: lo.ToPtr(95.0), Overage: lo.ToPtr(15.0), TotalAvailableGrantAmount: lo.ToPtr(95.0), }, @@ -212,7 +291,7 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { }, }, { - Name: "Usage percentage with no balance and usage", + Name: "Usage percentage with no grants", BalanceThresholds: []notification.BalanceThreshold{ newUsagePercentageThreshold(50), newUsagePercentageThreshold(100), @@ -222,28 +301,8 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { EntitlementValue: snapshot.EntitlementValue{ Balance: lo.ToPtr(0.0), Usage: lo.ToPtr(0.0), - TotalAvailableGrantAmount: lo.ToPtr(100.0), - }, - Expected: &activeThresholds{ - // 50% of 100 = 50, NOT < 0 usage, not active - // 100% of 100 = 100, NOT < 0 usage, not active - // 110% of 100 = 110, NOT < 0 usage, not active - // 120% of 100 = 120, NOT < 0 usage, not active - Usage: nil, - Balance: nil, - }, - }, - { - Name: "Usage percentage with no grants", - BalanceThresholds: []notification.BalanceThreshold{ - newUsagePercentageThreshold(50), - newUsagePercentageThreshold(100), - newUsagePercentageThreshold(110), - newUsagePercentageThreshold(120), - }, - EntitlementValue: snapshot.EntitlementValue{ - Balance: lo.ToPtr(0.0), - Usage: lo.ToPtr(100.0), + Overage: lo.ToPtr(100.0), + TotalAvailableGrantAmount: lo.ToPtr(0.0), }, Expected: &activeThresholds{ Usage: nil, @@ -626,7 +685,7 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { Balance: lo.ToPtr(0.0), Usage: lo.ToPtr(30.0), Overage: lo.ToPtr(10.0), - TotalAvailableGrantAmount: lo.ToPtr(20.0), + TotalAvailableGrantAmount: lo.ToPtr(30.0), }, Expected: &activeThresholds{ Usage: nil, @@ -637,62 +696,20 @@ func Test_GetActiveThresholdsWithHighestPriority(t *testing.T) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { + // Make sure we test valid entitlement values + validateEntitlementValue(t, test.EntitlementValue) + actual, err := getActiveThresholdsWithHighestPriority(test.BalanceThresholds, test.EntitlementValue) - assert.NoErrorf(t, err, "must not return an error: %s", err) - assert.Equalf(t, test.Expected, actual, "must be equal") + if test.ExpectedErr == nil { + assert.NoErrorf(t, err, "must not return an error: %s", err) + assert.Equalf(t, test.Expected, actual, "must be equal") + } else { + assert.ErrorIsf(t, err, test.ExpectedErr, "must return the expected error: %s", err) + } }) } } -func Test_GetActiveThresholdsWithHighestPriority_Error(t *testing.T) { - t.Run("Balance and overage both positive", func(t *testing.T) { - _, err := getActiveThresholdsWithHighestPriority( - []notification.BalanceThreshold{ - newUsageValueThreshold(20), - }, - snapshot.EntitlementValue{ - Balance: lo.ToPtr(10.0), - Usage: lo.ToPtr(25.0), - Overage: lo.ToPtr(5.0), - TotalAvailableGrantAmount: lo.ToPtr(30.0), - }, - ) - assert.Error(t, err, "must return an error when balance and overage are both positive") - }) - - t.Run("Near absoluteZero boundary - balance and overage at threshold", func(t *testing.T) { - // absoluteZero is 1e-9; values at exactly 1e-9 should trigger the error - _, err := getActiveThresholdsWithHighestPriority( - []notification.BalanceThreshold{ - newUsageValueThreshold(20), - }, - snapshot.EntitlementValue{ - Balance: lo.ToPtr(1e-9), - Usage: lo.ToPtr(25.0), - Overage: lo.ToPtr(1e-9), - TotalAvailableGrantAmount: lo.ToPtr(30.0), - }, - ) - // 1e-9 is NOT > 1e-9 (absoluteZero), so this should NOT error - assert.NoError(t, err, "values at exactly absoluteZero should not trigger the validation error") - }) - - t.Run("Near absoluteZero boundary - balance and overage above threshold", func(t *testing.T) { - _, err := getActiveThresholdsWithHighestPriority( - []notification.BalanceThreshold{ - newUsageValueThreshold(20), - }, - snapshot.EntitlementValue{ - Balance: lo.ToPtr(1e-8), - Usage: lo.ToPtr(25.0), - Overage: lo.ToPtr(1e-8), - TotalAvailableGrantAmount: lo.ToPtr(30.0), - }, - ) - assert.Error(t, err, "values above absoluteZero should trigger the validation error") - }) -} - func MustParseISOTime(t *testing.T, str string) time.Time { t.Helper()