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
119 changes: 119 additions & 0 deletions credit_overage_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
29 changes: 18 additions & 11 deletions models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we need to have a map, document what the [string] is here.

But do we need a map here? Could we have a slice of credit ids instead? It's a narrower set of states.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll add the documentation, but i'm going to keep it as a map because to match the rust rulesengine.

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
}
Expand Down
12 changes: 12 additions & 0 deletions rulecheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading