diff --git a/credit_overage_test.go b/credit_overage_test.go new file mode 100644 index 0000000..21a633f --- /dev/null +++ b/credit_overage_test.go @@ -0,0 +1,119 @@ +package rulesengine_test + +import ( + "context" + "testing" + + "github.com/schematichq/rulesengine" + "github.com/schematichq/rulesengine/null" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// SCHX-582: credit overage. With overage enabled on a credit, consumption +// continues past a zero balance and accrues at a configured rate, so the balance +// stops gating the check. +// +// These mirror credit_overage_tests in rulesengine-rust; the two engines must +// agree (SCHY-515) for as long as both are in use. +func TestCreditOverage(t *testing.T) { + ctx := context.Background() + + const creditID = "test-credit-id" + + companyWith := func(balance float64, overage *bool) *rulesengine.Company { + company := createTestCompany() + company.CreditBalances = map[string]float64{creditID: balance} + if overage != nil { + company.CreditOverageEnabled = map[string]bool{creditID: *overage} + } + return company + } + + creditRule := func() *rulesengine.Rule { + rule := createTestRule() + condition := createTestCondition(rulesengine.ConditionTypeCredit) + condition.CreditID = null.Nullable(creditID) + condition.ConsumptionRate = null.Nullable(1.0) + rule.Conditions = []*rulesengine.Condition{condition} + return rule + } + + matches := func(t *testing.T, company *rulesengine.Company) bool { + t.Helper() + + result, err := rulesengine.NewRuleCheckService().Check(ctx, &rulesengine.CheckScope{ + Company: company, + Rule: creditRule(), + }) + require.NoError(t, err) + return result.Match + } + + // The existing hard stop, unchanged when nobody has opted in. + t.Run("denies at zero without overage", func(t *testing.T) { + assert.False(t, matches(t, companyWith(0, nil))) + }) + + t.Run("allows with balance", func(t *testing.T) { + assert.True(t, matches(t, companyWith(5, nil))) + }) + + // The point of the feature. + t.Run("allows past zero with overage enabled", func(t *testing.T) { + assert.True(t, matches(t, companyWith(0, null.Nullable(true)))) + }) + + // A negative balance is legal (SCH-5103); overage is what makes it billable, + // and an already-overdrafted company must keep working. + t.Run("allows when already negative", func(t *testing.T) { + assert.True(t, matches(t, companyWith(-40, null.Nullable(true)))) + }) + + // Explicitly false must behave exactly like absent, or switching the setting + // back off would not restore the hard stop. + t.Run("explicit false still denies", func(t *testing.T) { + assert.False(t, matches(t, companyWith(0, null.Nullable(false)))) + }) + + // Overage is per credit: enabling it on one must not unblock another. + t.Run("does not leak across credits", func(t *testing.T) { + company := companyWith(0, null.Nullable(true)) + company.CreditBalances["other-credit-id"] = 0 + + rule := createTestRule() + condition := createTestCondition(rulesengine.ConditionTypeCredit) + condition.CreditID = null.Nullable("other-credit-id") + condition.ConsumptionRate = null.Nullable(1.0) + rule.Conditions = []*rulesengine.Condition{condition} + + result, err := rulesengine.NewRuleCheckService().Check(ctx, &rulesengine.CheckScope{ + Company: company, + Rule: rule, + }) + require.NoError(t, err) + assert.False(t, result.Match, "the other credit has no overage and no balance") + }) + + // Overage has to beat the more specific branches too. A caller-supplied + // credit cost would otherwise re-impose the balance gate it short-circuits. + t.Run("overrides the credit cost option", func(t *testing.T) { + flag := createTestFlag() + // Pinned false: createTestFlag randomizes DefaultValue, and CheckFlag falls + // back to it when no rule matches — so a true default would let this pass + // without the rule ever matching. + flag.DefaultValue = false + flag.Rules = []*rulesengine.Rule{creditRule()} + + result, err := rulesengine.CheckFlag( + ctx, + companyWith(0, null.Nullable(true)), + nil, + flag, + rulesengine.WithCreditCost(creditID, 25), + ) + require.NoError(t, err) + assert.True(t, result.Value) + }) +} diff --git a/models.go b/models.go index 53ee5e1..2ec50eb 100644 --- a/models.go +++ b/models.go @@ -169,17 +169,24 @@ type Company struct { AccountID string `json:"account_id"` EnvironmentID string `json:"environment_id"` - BasePlanID *string `json:"base_plan_id"` - BillingProductIDs JSONSlice[string] `json:"billing_product_ids"` - CreditBalances map[string]float64 `json:"credit_balances"` - Entitlements JSONSlice[*FeatureEntitlement] `json:"entitlements,omitempty"` - Keys map[string]string `json:"keys"` - Metrics CompanyMetricCollection `json:"metrics"` - PlanIDs JSONSlice[string] `json:"plan_ids"` - PlanVersionIDs JSONSlice[string] `json:"plan_version_ids"` - Rules JSONSlice[*Rule] `json:"rules"` - Subscription *Subscription `json:"subscription"` - Traits JSONSlice[*Trait] `json:"traits"` + BasePlanID *string `json:"base_plan_id"` + BillingProductIDs JSONSlice[string] `json:"billing_product_ids"` + CreditBalances map[string]float64 `json:"credit_balances"` + // CreditOverageEnabled is the per-credit overage opt-in (SCHX-582), keyed by + // billing credit ID — the same key CreditBalances uses. When true for a + // credit, consumption continues past a zero balance and accrues against an + // overage rate rather than being denied, so the balance stops gating the + // check. Absent or false is the historical behaviour, so a caller that does + // not send the field keeps hard-stopping at zero. + CreditOverageEnabled map[string]bool `json:"credit_overage_enabled"` + Entitlements JSONSlice[*FeatureEntitlement] `json:"entitlements,omitempty"` + Keys map[string]string `json:"keys"` + Metrics CompanyMetricCollection `json:"metrics"` + PlanIDs JSONSlice[string] `json:"plan_ids"` + PlanVersionIDs JSONSlice[string] `json:"plan_version_ids"` + Rules JSONSlice[*Rule] `json:"rules"` + Subscription *Subscription `json:"subscription"` + Traits JSONSlice[*Trait] `json:"traits"` mu sync.Mutex `json:"-"` // mutex for thread safety } diff --git a/rulecheck.go b/rulecheck.go index ec7a30c..5363a59 100644 --- a/rulecheck.go +++ b/rulecheck.go @@ -147,6 +147,18 @@ func (s *RuleCheckService) checkCreditBalanceCondition(ctx context.Context, scop } } + // SCHX-582: with overage enabled the balance no longer gates the check — + // consumption continues past zero and accrues at the configured rate, so + // every branch below (all of which compare against the balance) would ask + // the wrong question. There is no cap in the current design, so once overage + // is on there is nothing further to compare against. + // + // Mirrors check_credit_balance_condition in rulesengine-rust; the two must + // agree (see SCHY-515) until the Go engine is retired. + if scope.Company.CreditOverageEnabled[*condition.CreditID] { + return true, nil + } + // Precedence on credit-balance conditions, most specific first. No // options supplied falls through to the legacy single-unit check. // 1. creditCost[credit_id]: caller-supplied per-call cost in credits;