diff --git a/creditspendpolicy.go b/creditspendpolicy.go new file mode 100644 index 0000000..2272f8e --- /dev/null +++ b/creditspendpolicy.go @@ -0,0 +1,152 @@ +package rulesengine + +import ( + "fmt" + "strconv" +) + +// CreditSpendPolicyKind is the extension point: a new way to limit spending +// arrives as a new kind here, not as another field on Company and User. +type CreditSpendPolicyKind string + +const ( + CreditSpendPolicyKindPerDraw CreditSpendPolicyKind = "per_draw" + CreditSpendPolicyKindWindow CreditSpendPolicyKind = "window" +) + +type CreditSpendPolicyScope string + +const ( + CreditSpendPolicyScopeCompany CreditSpendPolicyScope = "company" + CreditSpendPolicyScopeUser CreditSpendPolicyScope = "user" + CreditSpendPolicyScopeGroup CreditSpendPolicyScope = "group" +) + +type CreditSpendWindow struct { + Unit string `json:"unit" desc:"The period the limit accumulates over"` + Count int `json:"count" desc:"How many units make up one period"` +} + +type CreditSpendPolicy struct { + ID string `json:"id" desc:"The ID of the policy"` + CreditID string `json:"credit_id" desc:"The credit the policy limits"` + Kind CreditSpendPolicyKind `json:"kind" desc:"How the limit is applied"` + Scope CreditSpendPolicyScope `json:"scope" desc:"Whether the policy limits the company or one user"` + Label *string `json:"label,omitempty" desc:"The name the account gave the policy"` + Limit float64 `json:"limit" desc:"The ceiling, in credits"` + Consumed float64 `json:"consumed,omitempty" desc:"How much of the limit is already spent in the current period"` + Window *CreditSpendWindow `json:"window,omitempty" desc:"For a windowed limit, the period it accumulates over"` +} + +func (p *CreditSpendPolicy) Remaining() float64 { + if p == nil { + return 0 + } + + return max(p.Limit-p.Consumed, 0) +} + +// Equal compares Window and Label by value: both are pointers rebuilt on every +// projection, so a pointer compare would call every rebuild a change. +func (p *CreditSpendPolicy) Equal(other *CreditSpendPolicy) bool { + if p == nil || other == nil { + return p == other + } + + if p.ID != other.ID || + p.CreditID != other.CreditID || + p.Kind != other.Kind || + p.Scope != other.Scope || + p.Limit != other.Limit || + p.Consumed != other.Consumed { + return false + } + + if (p.Label == nil) != (other.Label == nil) { + return false + } + if p.Label != nil && *p.Label != *other.Label { + return false + } + + if (p.Window == nil) != (other.Window == nil) { + return false + } + if p.Window != nil && *p.Window != *other.Window { + return false + } + + return true +} + +// Allows reports whether a draw costing cost fits inside this policy. evaluated +// is false for a kind this engine does not recognise; callers treat that as +// absent, so an engine older than the kind lets the draw through rather than +// blocking every check against the credit. +func (p *CreditSpendPolicy) Allows(cost float64) (allowed bool, evaluated bool) { + if p == nil { + return true, false + } + + switch p.Kind { + case CreditSpendPolicyKindPerDraw: + return cost <= p.Limit, true + case CreditSpendPolicyKindWindow: + return p.Consumed+cost <= p.Limit, true + default: + return true, false + } +} + +func (p *CreditSpendPolicy) Describe() string { + if p == nil { + return "" + } + + limit := formatCreditAmount(p.Limit) + switch p.Kind { + case CreditSpendPolicyKindWindow: + if p.Window == nil { + return fmt.Sprintf("%s limit of %s credits per period", p.Scope, limit) + } + if p.Window.Count == 1 { + return fmt.Sprintf("%s limit of %s credits per %s", p.Scope, limit, p.Window.Unit) + } + return fmt.Sprintf("%s limit of %s credits per %d %ss", p.Scope, limit, p.Window.Count, p.Window.Unit) + default: + return fmt.Sprintf("%s limit of %s credits per request", p.Scope, limit) + } +} + +func formatCreditAmount(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} + +// creditSpendPolicyRefusal returns the first policy refusing a draw of cost, or +// nil when every applicable policy allows it. Any refusal blocks: kinds are not +// comparable by a single number, so there is no "tightest" one to pick. +func creditSpendPolicyRefusal(scope *CheckScope, creditID string, cost float64) *CreditSpendPolicy { + if scope == nil { + return nil + } + + var policies []*CreditSpendPolicy + if scope.Company != nil { + policies = append(policies, scope.Company.CreditSpendPolicies...) + } + if scope.User != nil { + policies = append(policies, scope.User.CreditSpendPolicies...) + } + + for _, policy := range policies { + if policy == nil || policy.CreditID != creditID { + continue + } + allowed, evaluated := policy.Allows(cost) + if evaluated && !allowed { + return policy + } + } + + return nil +} diff --git a/creditspendpolicy_test.go b/creditspendpolicy_test.go new file mode 100644 index 0000000..25e79bc --- /dev/null +++ b/creditspendpolicy_test.go @@ -0,0 +1,302 @@ +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" +) + +const spendBalanceID = "bcrd_spendpolicy" + +// spendPolicyFlag builds a flag whose only rule is a credit-balance condition on +// creditID, so a check exercises exactly the spend-policy gate. +func spendPolicyFlag(creditID string, consumptionRate float64) *rulesengine.Flag { + condition := createTestCondition(rulesengine.ConditionTypeCredit) + condition.Operator = typeconvert.ComparableOperatorGte + condition.CreditID = &creditID + condition.ConsumptionRate = null.Nullable(consumptionRate) + + rule := createTestRule() + rule.Conditions = []*rulesengine.Condition{condition} + + flag := createTestFlag() + flag.DefaultValue = false + flag.Rules = []*rulesengine.Rule{rule} + return flag +} + +func perDrawPolicy(limit float64, scope rulesengine.CreditSpendPolicyScope) *rulesengine.CreditSpendPolicy { + return &rulesengine.CreditSpendPolicy{ + ID: "csp_" + string(scope), + CreditID: spendBalanceID, + Kind: rulesengine.CreditSpendPolicyKindPerDraw, + Scope: scope, + Limit: limit, + } +} + +func windowPolicy(limit float64, consumed float64) *rulesengine.CreditSpendPolicy { + return &rulesengine.CreditSpendPolicy{ + ID: "csp_window", + CreditID: spendBalanceID, + Kind: rulesengine.CreditSpendPolicyKindWindow, + Scope: rulesengine.CreditSpendPolicyScopeCompany, + Limit: limit, + Consumed: consumed, + Window: &rulesengine.CreditSpendWindow{Unit: "day", Count: 1}, + } +} + +// companyWith returns a funded company carrying the given policies. +func companyWith(policies ...*rulesengine.CreditSpendPolicy) *rulesengine.Company { + company := createTestCompany() + company.CreditBalances = map[string]float64{spendBalanceID: 10000} + company.CreditSpendPolicies = policies + return company +} + +// TestCreditSpendPolicyAllows pins the per-kind verdict in isolation, including +// the fail-open contract for a kind this engine does not recognise. +func TestCreditSpendPolicyAllows(t *testing.T) { + tests := []struct { + name string + policy *rulesengine.CreditSpendPolicy + cost float64 + wantAllowed bool + wantEvaluated bool + }{ + {"per draw under the limit", perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany), 5, true, true}, + {"per draw at the limit", perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany), 10, true, true}, + {"per draw over the limit", perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany), 11, false, true}, + {"per draw ignores nothing spent", perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany), 10, true, true}, + {"window with room", windowPolicy(100, 60), 40, true, true}, + {"window exactly full", windowPolicy(100, 60), 41, false, true}, + {"window already exhausted", windowPolicy(100, 100), 1, false, true}, + // A draw that would fit a per-draw cap of the same size is still + // refused once the period is spent — the two kinds are not + // interchangeable, which is why one number cannot represent both. + {"window refuses a draw a per-draw cap would allow", windowPolicy(100, 95), 50, false, true}, + { + name: "an unrecognised kind is skipped rather than enforced", + policy: &rulesengine.CreditSpendPolicy{CreditID: spendBalanceID, Kind: "rolling_average", Limit: 1}, + cost: 1000, + wantAllowed: true, + wantEvaluated: false, + }, + {"a nil policy is skipped", nil, 1000, true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allowed, evaluated := tt.policy.Allows(tt.cost) + assert.Equal(t, tt.wantAllowed, allowed) + assert.Equal(t, tt.wantEvaluated, evaluated) + }) + } +} + +func TestCreditSpendPolicyRemaining(t *testing.T) { + assert.Equal(t, float64(10), perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany).Remaining()) + assert.Equal(t, float64(40), windowPolicy(100, 60).Remaining()) + // Over-consumption clamps at zero rather than reporting negative headroom. + assert.Equal(t, float64(0), windowPolicy(100, 140).Remaining()) +} + +func TestCreditSpendPolicyCheckFlag(t *testing.T) { + ctx := context.Background() + + t.Run("no policy leaves the check on the balance alone", func(t *testing.T) { + result, err := rulesengine.CheckFlag(ctx, companyWith(), nil, spendPolicyFlag(spendBalanceID, 1), + rulesengine.WithCreditCost(spendBalanceID, 50)) + + assert.NoError(t, err) + assert.True(t, result.Value) + assert.Nil(t, result.CreditSpendPolicy) + }) + + t.Run("a per-draw policy refuses an over-limit draw despite a funded balance", func(t *testing.T) { + policy := perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany) + + result, err := rulesengine.CheckFlag(ctx, companyWith(policy), nil, spendPolicyFlag(spendBalanceID, 1), + rulesengine.WithCreditCost(spendBalanceID, 50)) + + assert.NoError(t, err) + assert.False(t, result.Value) + if assert.NotNil(t, result.CreditSpendPolicy) { + assert.Equal(t, float64(50), result.CreditSpendPolicy.Cost) + assert.Equal(t, policy, result.CreditSpendPolicy.Policy) + } + assert.Equal(t, rulesengine.ReasonCreditSpendPolicyExceeded(50, policy), result.Reason) + }) + + t.Run("a window policy refuses once the period is spent", func(t *testing.T) { + result, err := rulesengine.CheckFlag(ctx, companyWith(windowPolicy(100, 95)), nil, + spendPolicyFlag(spendBalanceID, 1), rulesengine.WithCreditCost(spendBalanceID, 10)) + + assert.NoError(t, err) + assert.False(t, result.Value) + if assert.NotNil(t, result.CreditSpendPolicy) { + assert.Equal(t, rulesengine.CreditSpendPolicyKindWindow, result.CreditSpendPolicy.Policy.Kind) + } + }) + + t.Run("a draw fitting every policy passes", func(t *testing.T) { + result, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(50, rulesengine.CreditSpendPolicyScopeCompany), windowPolicy(100, 20)), + nil, spendPolicyFlag(spendBalanceID, 1), rulesengine.WithCreditCost(spendBalanceID, 40)) + + assert.NoError(t, err) + assert.True(t, result.Value) + assert.Nil(t, result.CreditSpendPolicy) + }) + + t.Run("any refusal blocks, whichever policy it is", func(t *testing.T) { + // The per-draw cap allows 40; the window has only 5 left. Neither is + // "tighter" as a single number, so the engine refuses on the one that + // actually fails. + result, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(50, rulesengine.CreditSpendPolicyScopeCompany), windowPolicy(100, 95)), + nil, spendPolicyFlag(spendBalanceID, 1), rulesengine.WithCreditCost(spendBalanceID, 40)) + + assert.NoError(t, err) + assert.False(t, result.Value) + if assert.NotNil(t, result.CreditSpendPolicy) { + assert.Equal(t, rulesengine.CreditSpendPolicyKindWindow, result.CreditSpendPolicy.Policy.Kind) + } + }) + + t.Run("a user policy binds alongside the company's", func(t *testing.T) { + user := createTestUser() + user.CreditSpendPolicies = []*rulesengine.CreditSpendPolicy{ + perDrawPolicy(5, rulesengine.CreditSpendPolicyScopeUser), + } + + result, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(500, rulesengine.CreditSpendPolicyScopeCompany)), user, + spendPolicyFlag(spendBalanceID, 1), rulesengine.WithCreditCost(spendBalanceID, 10)) + + assert.NoError(t, err) + assert.False(t, result.Value) + if assert.NotNil(t, result.CreditSpendPolicy) { + assert.Equal(t, rulesengine.CreditSpendPolicyScopeUser, result.CreditSpendPolicy.Policy.Scope) + } + }) + + t.Run("a user policy does not bind a check with no user", func(t *testing.T) { + result, err := rulesengine.CheckFlag(ctx, companyWith(), nil, spendPolicyFlag(spendBalanceID, 1), + rulesengine.WithCreditCost(spendBalanceID, 10)) + + assert.NoError(t, err) + assert.True(t, result.Value) + }) + + t.Run("a policy on another credit does not bind", func(t *testing.T) { + other := perDrawPolicy(1, rulesengine.CreditSpendPolicyScopeCompany) + other.CreditID = "bcrd_other" + + result, err := rulesengine.CheckFlag(ctx, companyWith(other), nil, spendPolicyFlag(spendBalanceID, 1), + rulesengine.WithCreditCost(spendBalanceID, 50)) + + assert.NoError(t, err) + assert.True(t, result.Value) + }) + + t.Run("WithUsage draws are costed at quantity x consumption rate", func(t *testing.T) { + // 4 units x 2 credits = 8, under the limit of 10. + allowed, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany)), nil, + spendPolicyFlag(spendBalanceID, 2), rulesengine.WithUsage(4)) + assert.NoError(t, err) + assert.True(t, allowed.Value) + + // 6 units x 2 credits = 12, over it. + refused, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany)), nil, + spendPolicyFlag(spendBalanceID, 2), rulesengine.WithUsage(6)) + assert.NoError(t, err) + assert.False(t, refused.Value) + if assert.NotNil(t, refused.CreditSpendPolicy) { + assert.Equal(t, float64(12), refused.CreditSpendPolicy.Cost) + } + }) + + t.Run("a single-unit check is refused by a limit below the consumption rate", func(t *testing.T) { + result, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(1, rulesengine.CreditSpendPolicyScopeCompany)), nil, + spendPolicyFlag(spendBalanceID, 5)) + + assert.NoError(t, err) + assert.False(t, result.Value) + if assert.NotNil(t, result.CreditSpendPolicy) { + assert.Equal(t, float64(5), result.CreditSpendPolicy.Cost) + } + }) + + t.Run("a zero limit refuses every non-free draw", func(t *testing.T) { + refused, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(0, rulesengine.CreditSpendPolicyScopeCompany)), nil, + spendPolicyFlag(spendBalanceID, 1), rulesengine.WithCreditCost(spendBalanceID, 1)) + assert.NoError(t, err) + assert.False(t, refused.Value) + + free, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(0, rulesengine.CreditSpendPolicyScopeCompany)), nil, + spendPolicyFlag(spendBalanceID, 1), rulesengine.WithCreditCost(spendBalanceID, 0)) + assert.NoError(t, err) + assert.True(t, free.Value) + }) + + t.Run("an unrecognised kind lets the draw through", func(t *testing.T) { + unknown := &rulesengine.CreditSpendPolicy{CreditID: spendBalanceID, Kind: "rolling_average", Limit: 1} + + result, err := rulesengine.CheckFlag(ctx, companyWith(unknown), nil, spendPolicyFlag(spendBalanceID, 1), + rulesengine.WithCreditCost(spendBalanceID, 500)) + + assert.NoError(t, err) + assert.True(t, result.Value) + assert.Nil(t, result.CreditSpendPolicy) + }) + + t.Run("an insufficient balance still reports no rules matched", func(t *testing.T) { + company := companyWith(perDrawPolicy(100, rulesengine.CreditSpendPolicyScopeCompany)) + company.CreditBalances = map[string]float64{spendBalanceID: 1} + + result, err := rulesengine.CheckFlag(ctx, company, nil, spendPolicyFlag(spendBalanceID, 1), + rulesengine.WithCreditCost(spendBalanceID, 50)) + + assert.NoError(t, err) + assert.False(t, result.Value) + assert.Nil(t, result.CreditSpendPolicy) + assert.Equal(t, rulesengine.ReasonNoRulesMatched, result.Reason) + }) + + t.Run("a later matching rule wins over an earlier refusal", func(t *testing.T) { + flag := spendPolicyFlag(spendBalanceID, 1) + override := createTestRule() + override.RuleType = rulesengine.RuleTypeGlobalOverride + override.Value = true + flag.Rules = append(flag.Rules, override) + + result, err := rulesengine.CheckFlag(ctx, + companyWith(perDrawPolicy(1, rulesengine.CreditSpendPolicyScopeCompany)), nil, flag, + rulesengine.WithCreditCost(spendBalanceID, 50)) + + assert.NoError(t, err) + assert.True(t, result.Value) + assert.Nil(t, result.CreditSpendPolicy) + }) +} + +func TestCreditSpendPolicyDescribe(t *testing.T) { + assert.Equal(t, "company limit of 10 credits per request", + perDrawPolicy(10, rulesengine.CreditSpendPolicyScopeCompany).Describe()) + assert.Equal(t, "company limit of 100 credits per day", windowPolicy(100, 0).Describe()) + + multi := windowPolicy(100, 0) + multi.Window = &rulesengine.CreditSpendWindow{Unit: "hour", Count: 6} + assert.Equal(t, "company limit of 100 credits per 6 hours", multi.Describe()) +} diff --git a/flagcheck.go b/flagcheck.go index 868a5c6..a37c2b3 100644 --- a/flagcheck.go +++ b/flagcheck.go @@ -10,21 +10,22 @@ import ( ) type CheckFlagResult struct { - CompanyID *string `json:"company_id,omitempty"` - Err error `json:"err,omitempty"` - Entitlement *FeatureEntitlement `json:"entitlement,omitempty"` - FeatureAllocation *int64 `json:"feature_allocation,omitempty"` - FeatureUsage *int64 `json:"feature_usage,omitempty"` - FeatureUsageEvent *string `json:"feature_usage_event,omitempty"` - FeatureUsagePeriod *MetricPeriod `json:"feature_usage_period,omitempty" binding:"oneof=all_time current_day current_month current_week"` - FeatureUsageResetAt *time.Time `json:"feature_usage_reset_at,omitempty"` - FlagID *string `json:"flag_id,omitempty"` - FlagKey string `json:"flag_key"` - Reason string `json:"reason"` - RuleID *string `json:"rule_id,omitempty"` - RuleType *RuleType `json:"rule_type,omitempty" binding:"oneof=default global_override company_override company_override_usage_exceeded plan_entitlement plan_entitlement_usage_exceeded standard"` - UserID *string `json:"user_id,omitempty"` - Value bool `json:"value"` + CompanyID *string `json:"company_id,omitempty"` + Err error `json:"err,omitempty"` + Entitlement *FeatureEntitlement `json:"entitlement,omitempty"` + FeatureAllocation *int64 `json:"feature_allocation,omitempty"` + FeatureUsage *int64 `json:"feature_usage,omitempty"` + FeatureUsageEvent *string `json:"feature_usage_event,omitempty"` + FeatureUsagePeriod *MetricPeriod `json:"feature_usage_period,omitempty" binding:"oneof=all_time current_day current_month current_week"` + FeatureUsageResetAt *time.Time `json:"feature_usage_reset_at,omitempty"` + CreditSpendPolicy *CreditSpendPolicyResult `json:"credit_spend_policy,omitempty"` + FlagID *string `json:"flag_id,omitempty"` + FlagKey string `json:"flag_key"` + Reason string `json:"reason"` + RuleID *string `json:"rule_id,omitempty"` + RuleType *RuleType `json:"rule_type,omitempty" binding:"oneof=default global_override company_override company_override_usage_exceeded plan_entitlement plan_entitlement_usage_exceeded standard"` + UserID *string `json:"user_id,omitempty"` + Value bool `json:"value"` } const ( @@ -37,6 +38,22 @@ const ( ReasonUserNotFound = "User not found" ) +// ReasonCreditSpendPolicyExceeded replaces ReasonNoRulesMatched only when a +// policy was what stopped an entitlement rule from matching. +func ReasonCreditSpendPolicyExceeded(cost float64, policy *CreditSpendPolicy) string { + return fmt.Sprintf( + "A draw of %s credits exceeds the %s", + formatCreditAmount(cost), policy.Describe(), + ) +} + +// CreditSpendPolicyResult lets a caller tell a policy refusal from an empty +// balance, and show the actor what bound it. +type CreditSpendPolicyResult struct { + Cost float64 `json:"cost" desc:"The cost of the draw the check was evaluated against"` + Policy *CreditSpendPolicy `json:"policy" desc:"The policy that refused the draw"` +} + func (r *CheckFlagResult) setRuleFields(company *Company, rule *Rule) { if rule == nil { return @@ -168,6 +185,10 @@ func CheckFlag( } } } + // Shared across every rule so an earlier refusal survives to the post-loop + // reason check below. + spendPolicy := &spendPolicyBlock{} + for _, group := range GroupRulesByPriority(flag.Rules, companyRules, userRules) { for _, rule := range group { if rule == nil { @@ -175,12 +196,13 @@ func CheckFlag( } checkRuleResp, err := ruleChecker.Check(ctx, &CheckScope{ - Company: company, - Rule: rule, - User: user, - creditCost: options.creditCost, - usage: options.usage, - eventUsage: options.eventUsage, + Company: company, + Rule: rule, + User: user, + creditCost: options.creditCost, + usage: options.usage, + eventUsage: options.eventUsage, + spendPolicy: spendPolicy, }) if err != nil { resp.Err = err @@ -201,6 +223,16 @@ func CheckFlag( } } + // Say so when a policy refused a credit condition along the way; the caller + // would otherwise read the generic no-rules reason and blame the balance. + if spendPolicy.policy != nil { + resp.Reason = ReasonCreditSpendPolicyExceeded(spendPolicy.cost, spendPolicy.policy) + resp.CreditSpendPolicy = &CreditSpendPolicyResult{ + Cost: spendPolicy.cost, + Policy: spendPolicy.policy, + } + } + return resp, nil } diff --git a/models.go b/models.go index 53ee5e1..84a89b8 100644 --- a/models.go +++ b/models.go @@ -169,17 +169,18 @@ 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"` + CreditSpendPolicies JSONSlice[*CreditSpendPolicy] `json:"credit_spend_policies,omitempty"` + 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 } @@ -238,7 +239,8 @@ type User struct { AccountID string `json:"account_id"` EnvironmentID string `json:"environment_id"` - Keys map[string]string `json:"keys"` - Traits JSONSlice[*Trait] `json:"traits"` - Rules JSONSlice[*Rule] `json:"rules"` + CreditSpendPolicies JSONSlice[*CreditSpendPolicy] `json:"credit_spend_policies,omitempty"` + Keys map[string]string `json:"keys"` + Traits JSONSlice[*Trait] `json:"traits"` + Rules JSONSlice[*Rule] `json:"rules"` } diff --git a/rulecheck.go b/rulecheck.go index ec7a30c..634b6a0 100644 --- a/rulecheck.go +++ b/rulecheck.go @@ -20,6 +20,26 @@ type CheckScope struct { creditCost map[string]float64 usage *int64 eventUsage *eventUsage + + // spendPolicy is shared across every rule CheckFlag evaluates for one flag. + // A refused condition just fails to match, so without this the caller sees + // ReasonNoRulesMatched and cannot tell a policy from an empty balance. + spendPolicy *spendPolicyBlock +} + +type spendPolicyBlock struct { + cost float64 + policy *CreditSpendPolicy +} + +// record keeps the first refusal, so the reason is stable across rule reordering. +func (b *spendPolicyBlock) record(cost float64, policy *CreditSpendPolicy) { + if b == nil || b.policy != nil { + return + } + + b.cost = cost + b.policy = policy } type CheckResult struct { @@ -148,29 +168,39 @@ func (s *RuleCheckService) checkCreditBalanceCondition(ctx context.Context, scop } // 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; - // gate on balance >= cost. + // options supplied falls through to the legacy single-unit cost. + // 1. creditCost[credit_id]: caller-supplied per-call cost in credits. // 2. eventUsage, when its event_subtype matches the condition's: - // simulated quantity for this specific event; gate on - // balance >= quantity × consumption_rate. - // 3. usage: generic quantity (no event disambiguation); gate on - // balance >= quantity × consumption_rate. - // 4. Legacy: balance >= consumption_rate (single unit). - if cost, ok := scope.creditCost[*condition.CreditID]; ok { - return creditBalance >= cost, nil - } - - if eu := scope.eventUsage; eu != nil && condition.EventSubtype != nil && - eu.eventSubtype == *condition.EventSubtype && eu.quantity > 0 { - return creditBalance >= float64(eu.quantity)*consumptionRate, nil - } - - if scope.usage != nil && *scope.usage > 0 { - return creditBalance >= float64(*scope.usage)*consumptionRate, nil + // quantity × consumption_rate for this specific event. + // 3. usage: generic quantity (no event disambiguation); + // quantity × consumption_rate. + // 4. Legacy: consumption_rate (single unit). + // The resolved cost gates both the balance and the policies, so the two + // always judge the same draw. + suppliedCost, hasCost := scope.creditCost[*condition.CreditID] + eu := scope.eventUsage + if eu != nil && (condition.EventSubtype == nil || eu.eventSubtype != *condition.EventSubtype || eu.quantity <= 0) { + eu = nil + } + + cost := consumptionRate + switch { + case hasCost: + cost = suppliedCost + case eu != nil: + cost = float64(eu.quantity) * consumptionRate + case scope.usage != nil && *scope.usage > 0: + cost = float64(*scope.usage) * consumptionRate + } + + // Checked before the balance so a company with credit to spare still fails + // when the draw breaks a policy, and the reason names the policy. + if refused := creditSpendPolicyRefusal(scope, *condition.CreditID, cost); refused != nil { + scope.spendPolicy.record(cost, refused) + return false, nil } - return creditBalance >= consumptionRate, nil + return creditBalance >= cost, nil } func (s *RuleCheckService) checkBillingProductCondition(ctx context.Context, company *Company, condition *Condition) (bool, error) {