From cdc370620073a888bee72e2011495fdbc8475ae4 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 1 Sep 2026 09:13:55 -0600 Subject: [PATCH 1/2] allow past a zero credit balance when overage is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors check_credit_balance_condition in rulesengine-rust, one for one, including the seven tests. The two engines must agree while both are in use — SCHY-515 was opened over exactly this kind of divergence. Company gains a per-credit CreditOverageEnabled map, defaulting nil so a caller that does not send it keeps the existing hard stop; indexing a nil map yields false, which is the behaviour we want. The check short-circuits before the precedence chain rather than joining it. All four of those branches compare against the balance, and with overage on the balance is no longer the question — including the caller-supplied credit cost branch, which would otherwise re-impose the gate. With no cap in the current design there is nothing else to compare against. This library is slated for removal by schematichq/api#7525, which moves the API onto the wasm engine. Changing it anyway because that PR is not close to merging and SCHX-582 has no working enforcement without it. --- credit_overage_test.go | 115 +++++++++++++++++++++++++++++++++++++++++ models.go | 28 ++++++---- rulecheck.go | 12 +++++ 3 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 credit_overage_test.go diff --git a/credit_overage_test.go b/credit_overage_test.go new file mode 100644 index 0000000..755e380 --- /dev/null +++ b/credit_overage_test.go @@ -0,0 +1,115 @@ +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() + 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..446ceaf 100644 --- a/models.go +++ b/models.go @@ -169,17 +169,23 @@ 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). 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; From 737fc03ae48e97acb8f79787452ef5e9e17b0ec5 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 1 Sep 2026 10:19:50 -0600 Subject: [PATCH 2/2] make the credit cost test actually test something Reverting the overage check left this subtest passing: createTestFlag randomizes DefaultValue, and CheckFlag falls back to it when no rule matches, so the assertion was reading a coin flip rather than the rule. It was flaky for the same reason. Pinning the default false means it can only pass when the rule genuinely matches -- reverting the check now fails all three positive subtests instead of one. Also names the map key as a billing credit ID, per review. --- credit_overage_test.go | 4 ++++ models.go | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/credit_overage_test.go b/credit_overage_test.go index 755e380..21a633f 100644 --- a/credit_overage_test.go +++ b/credit_overage_test.go @@ -100,6 +100,10 @@ func TestCreditOverage(t *testing.T) { // 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( diff --git a/models.go b/models.go index 446ceaf..2ec50eb 100644 --- a/models.go +++ b/models.go @@ -172,9 +172,10 @@ type Company struct { 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). 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 + // 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"`