Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions credit_entitlement_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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)
})

// 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)
})
}
4 changes: 3 additions & 1 deletion flagcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions rulecheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading