From 7142f16fc03e31d421a449fe879fb4d6ba2c60f4 Mon Sep 17 00:00:00 2001 From: ryan echternacht Date: Thu, 3 Sep 2026 14:54:23 -0400 Subject: [PATCH 1/2] honor the operator on credit balance conditions A credit burndown entitlement is written as an entitling rule with an lt credit condition and a usage-exceeded rule with a gte one, but the engine returned balance >= cost for both, so the exceeded rule could never match. A drained balance fell through to the flag default with no rule_type, which is indistinguishable from a plan that lacks the feature and stays entitled on any flag whose default is true. --- credit_entitlement_test.go | 116 +++++++++++++++++++++++++++++++++++++ flagcheck_test.go | 4 +- rulecheck.go | 23 +++++++- 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 credit_entitlement_test.go diff --git a/credit_entitlement_test.go b/credit_entitlement_test.go new file mode 100644 index 0000000..6a44f0d --- /dev/null +++ b/credit_entitlement_test.go @@ -0,0 +1,116 @@ +package rulesengine_test + +import ( + "context" + "testing" + + "github.com/schematichq/rulesengine" + "github.com/schematichq/rulesengine/null" + "github.com/schematichq/rulesengine/typeconvert" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A credit burndown entitlement is written by the API as a plan_entitlement +// rule (true, lt) and a plan_entitlement_usage_exceeded rule (false, gte), both +// carrying the same credit condition. The engine used to ignore the operator, so +// the exceeded rule could never match and a drained balance fell through to the +// flag default with no rule_type. +// +// Mirrors the credit entitlement tests in rulesengine-rust; the two engines must +// agree (SCHY-515) for as long as both are in use. +func TestCreditEntitlementExceededRule(t *testing.T) { + ctx := context.Background() + + const creditID = "test-credit-id" + + creditCondition := func(operator typeconvert.ComparableOperator) *rulesengine.Condition { + condition := createTestCondition(rulesengine.ConditionTypeCredit) + condition.Operator = operator + condition.CreditID = null.Nullable(creditID) + condition.ConsumptionRate = null.Nullable(1.0) + return condition + } + + entitlementFlag := func() (*rulesengine.Flag, *rulesengine.Rule, *rulesengine.Rule) { + entitled := createTestRule() + entitled.RuleType = rulesengine.RuleTypePlanEntitlement + entitled.Value = true + entitled.Conditions = []*rulesengine.Condition{creditCondition(typeconvert.ComparableOperatorLt)} + + exceeded := createTestRule() + exceeded.RuleType = rulesengine.RuleTypePlanEntitlementUsageExceeded + exceeded.Value = false + exceeded.Conditions = []*rulesengine.Condition{creditCondition(typeconvert.ComparableOperatorGte)} + + flag := createTestFlag() + // A false default would hide a drained balance falling through to it. + flag.DefaultValue = true + flag.Rules = []*rulesengine.Rule{entitled, exceeded} + return flag, entitled, exceeded + } + + companyWith := func(balance float64) *rulesengine.Company { + company := createTestCompany() + company.CreditBalances = map[string]float64{creditID: balance} + return company + } + + t.Run("entitles while the balance covers the cost", func(t *testing.T) { + flag, entitled, _ := entitlementFlag() + + result, err := rulesengine.CheckFlag(ctx, companyWith(5), nil, flag) + + require.NoError(t, err) + assert.True(t, result.Value) + assert.Equal(t, &entitled.ID, result.RuleID) + assert.Equal(t, rulesengine.RuleTypePlanEntitlement, *result.RuleType) + }) + + t.Run("denies through the exceeded rule when drained", func(t *testing.T) { + flag, _, exceeded := entitlementFlag() + + result, err := rulesengine.CheckFlag(ctx, companyWith(0.5), nil, flag) + + require.NoError(t, err) + assert.False(t, result.Value, "a drained balance must deny, not fall through to the flag default") + assert.Equal(t, &exceeded.ID, result.RuleID) + assert.Equal(t, rulesengine.RuleTypePlanEntitlementUsageExceeded, *result.RuleType) + }) + + t.Run("denies through the exceeded rule with no balance row", func(t *testing.T) { + flag, _, exceeded := entitlementFlag() + + result, err := rulesengine.CheckFlag(ctx, createTestCompany(), nil, flag) + + require.NoError(t, err) + assert.False(t, result.Value) + assert.Equal(t, &exceeded.ID, result.RuleID) + }) + + t.Run("exceeded rule honors a preflight credit cost", func(t *testing.T) { + // Balance 10 covers one unit at rate 1 but not a 50-credit call. + flag, _, exceeded := entitlementFlag() + + result, err := rulesengine.CheckFlag(ctx, companyWith(10), nil, flag, rulesengine.WithCreditCost(creditID, 50)) + + require.NoError(t, err) + assert.False(t, result.Value) + assert.Equal(t, &exceeded.ID, result.RuleID) + }) + + // Uncapped overage removes the balance gate, so the exceeded rule must stay quiet + // even at zero. + t.Run("exceeded rule does not fire with uncapped overage", func(t *testing.T) { + flag, entitled, _ := entitlementFlag() + company := companyWith(0) + company.CreditOverage = map[string]*float64{creditID: nil} + + result, err := rulesengine.CheckFlag(ctx, company, nil, flag) + + require.NoError(t, err) + assert.True(t, result.Value) + assert.Equal(t, &entitled.ID, result.RuleID) + }) +} diff --git a/flagcheck_test.go b/flagcheck_test.go index aae6814..2577e58 100644 --- a/flagcheck_test.go +++ b/flagcheck_test.go @@ -885,7 +885,9 @@ func TestCheckFlag(t *testing.T) { creditFlag := func(creditID string, consumptionRate float64, eventSubtype *string) (*rulesengine.Flag, *rulesengine.Rule) { rule := createTestRule() condition := createTestCondition(rulesengine.ConditionTypeCredit) - condition.Operator = typeconvert.ComparableOperatorGte + // lt is what the API writes on an entitling rule's credit condition; + // gte marks the usage-exceeded rule and inverts it. + condition.Operator = typeconvert.ComparableOperatorLt condition.CreditID = &creditID condition.ConsumptionRate = null.Nullable(consumptionRate) condition.EventSubtype = eventSubtype diff --git a/rulecheck.go b/rulecheck.go index a5f3f41..f78d003 100644 --- a/rulecheck.go +++ b/rulecheck.go @@ -134,6 +134,25 @@ func (s *RuleCheckService) checkCreditBalanceCondition(ctx context.Context, scop return false, nil } + covered := s.creditBalanceCoversCost(scope, condition) + + // The API writes an entitlement's credit condition with lt on the entitling + // rule and gte on its usage-exceeded rule, the same pair a metric condition + // uses for usage < limit / usage >= limit. There is no spent figure to put on + // the left of that comparison, only the balance, so the operator selects a + // direction: gt/gte match when the balance does not cover the cost, + // everything else when it does. + switch condition.Operator { + case typeconvert.ComparableOperatorGt, typeconvert.ComparableOperatorGte: + return !covered, nil + default: + return covered, nil + } +} + +// creditBalanceCoversCost reports whether the company's balance for the +// condition's credit covers the cost this check would incur. +func (s *RuleCheckService) creditBalanceCoversCost(scope *CheckScope, condition *Condition) bool { consumptionRate := float64(1) if condition.ConsumptionRate != nil { consumptionRate = *condition.ConsumptionRate @@ -179,13 +198,13 @@ func (s *RuleCheckService) checkCreditBalanceCondition(ctx context.Context, scop var overageAllowance float64 if overageCap, overageOn := scope.Company.CreditOverage[*condition.CreditID]; overageOn { if overageCap == nil { - return true, nil + return true } overageAllowance = *overageCap } - return creditBalance+overageAllowance >= cost, nil + return creditBalance+overageAllowance >= cost } func (s *RuleCheckService) checkBillingProductCondition(ctx context.Context, company *Company, condition *Condition) (bool, error) { From d9a1284cea1ff0722abcfdf769d3a0d392750acb Mon Sep 17 00:00:00 2001 From: ryan echternacht Date: Fri, 4 Sep 2026 11:31:11 -0400 Subject: [PATCH 2/2] cover the exceeded rule firing once a capped overage is spent --- credit_entitlement_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/credit_entitlement_test.go b/credit_entitlement_test.go index 6a44f0d..60c5298 100644 --- a/credit_entitlement_test.go +++ b/credit_entitlement_test.go @@ -113,4 +113,27 @@ func TestCreditEntitlementExceededRule(t *testing.T) { assert.True(t, result.Value) assert.Equal(t, &entitled.ID, result.RuleID) }) + + // A capped overage moves the floor to -cap rather than removing it, so the + // exceeded rule fires once the cap is spent, the same as a drained balance + // with overage off. + t.Run("exceeded rule fires once a capped overage is spent", func(t *testing.T) { + flag, entitled, exceeded := entitlementFlag() + overageCap := 10.0 + + company := companyWith(-5) + company.CreditOverage = map[string]*float64{creditID: &overageCap} + result, err := rulesengine.CheckFlag(ctx, company, nil, flag) + require.NoError(t, err) + assert.True(t, result.Value, "still inside the cap") + assert.Equal(t, &entitled.ID, result.RuleID) + + company = companyWith(-10) + company.CreditOverage = map[string]*float64{creditID: &overageCap} + result, err = rulesengine.CheckFlag(ctx, company, nil, flag) + require.NoError(t, err) + assert.False(t, result.Value, "the cap is spent") + assert.Equal(t, &exceeded.ID, result.RuleID) + assert.Equal(t, rulesengine.RuleTypePlanEntitlementUsageExceeded, *result.RuleType) + }) }