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
18 changes: 9 additions & 9 deletions credit_entitlement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,12 @@ func TestCreditEntitlementExceededRule(t *testing.T) {
assert.Equal(t, &exceeded.ID, result.RuleID)
})

// Uncapped overage removes the balance gate, so the exceeded rule must stay quiet
// Unbounded postpaid 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) {
t.Run("exceeded rule does not fire with unbounded postpaid", func(t *testing.T) {
flag, entitled, _ := entitlementFlag()
company := companyWith(0)
company.CreditOverage = map[string]*float64{creditID: nil}
company.CreditPostpaidLimit = map[string]*float64{creditID: nil}

result, err := rulesengine.CheckFlag(ctx, company, nil, flag)

Expand All @@ -114,22 +114,22 @@ func TestCreditEntitlementExceededRule(t *testing.T) {
assert.Equal(t, &entitled.ID, result.RuleID)
})

// A capped overage moves the floor to -cap rather than removing it, so the
// A capped postpaid 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) {
// with postpaid off.
t.Run("exceeded rule fires once a capped postpaid is spent", func(t *testing.T) {
flag, entitled, exceeded := entitlementFlag()
overageCap := 10.0
overdraftLimit := 10.0

company := companyWith(-5)
company.CreditOverage = map[string]*float64{creditID: &overageCap}
company.CreditPostpaidLimit = map[string]*float64{creditID: &overdraftLimit}
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}
company.CreditPostpaidLimit = map[string]*float64{creditID: &overdraftLimit}
result, err = rulesengine.CheckFlag(ctx, company, nil, flag)
require.NoError(t, err)
assert.False(t, result.Value, "the cap is spent")
Expand Down
93 changes: 0 additions & 93 deletions credit_overage_serialization_test.go

This file was deleted.

123 changes: 123 additions & 0 deletions credit_postpaid_serialization_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package rulesengine_test

import (
"encoding/json"
"testing"

"github.com/schematichq/rulesengine"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// The postpaid map leans on a distinction JSON can express but is easy to lose in
// a hand-written client: a key present with a null value (postpaid on, unbounded)
// against the key being absent (postpaid off). Collapsing those turns "gate at
// zero" into "never gate", which is why it is asserted rather than assumed.
func TestCreditPostpaidLimitSerialization(t *testing.T) {
const creditID = "test-credit-id"

t.Run("decodes the three states distinctly", func(t *testing.T) {
for _, tc := range []struct {
name string
raw string
postpaidOn bool
unbounded bool
limit float64
}{
{name: "field absent", raw: `{}`},
{name: "field null", raw: `{"credit_postpaid_limit":null}`},
{name: "map empty", raw: `{"credit_postpaid_limit":{}}`},
{name: "value null is on and unbounded", raw: `{"credit_postpaid_limit":{"test-credit-id":null}}`, postpaidOn: true, unbounded: true},
{name: "value set is on and capped", raw: `{"credit_postpaid_limit":{"test-credit-id":100}}`, postpaidOn: true, limit: 100},
} {
t.Run(tc.name, func(t *testing.T) {
var company rulesengine.Company
require.NoError(t, json.Unmarshal([]byte(tc.raw), &company))

overdraftLimit, postpaidOn := company.CreditPostpaidLimit[creditID]
require.Equal(t, tc.postpaidOn, postpaidOn, "presence in the map is the opt-in")

if !tc.postpaidOn {
return
}

if tc.unbounded {
assert.Nil(t, overdraftLimit, "a null value must stay nil, not become zero")
return
}

require.NotNil(t, overdraftLimit)
assert.Equal(t, tc.limit, *overdraftLimit)
})
}
})

// Round-tripping must not quietly promote nil to 0. A zero cap denies every
// draw past the balance, which is the opposite of what nil means.
t.Run("round-trips without collapsing nil to zero", func(t *testing.T) {
limit := float64(100)
for name, postpaid := range map[string]map[string]*float64{
"unbounded": {creditID: nil},
"capped": {creditID: &limit},
"empty": {},
"nil map": nil,
} {
t.Run(name, func(t *testing.T) {
encoded, err := json.Marshal(rulesengine.Company{CreditPostpaidLimit: postpaid})
require.NoError(t, err)

var decoded rulesengine.Company
require.NoError(t, json.Unmarshal(encoded, &decoded))

assert.Len(t, decoded.CreditPostpaidLimit, len(postpaid))

want, wantOn := postpaid[creditID]
got, gotOn := decoded.CreditPostpaidLimit[creditID]
require.Equal(t, wantOn, gotOn)

if !wantOn {
return
}

if want == nil {
assert.Nil(t, got, "nil cap must not decode as zero")
return
}

require.NotNil(t, got)
assert.Equal(t, *want, *got)
})
}
})
}

// omitempty is load-bearing rather than cosmetic. A client generated from a spec
// that predates this field marks every property it knows as required and decodes
// strictly; if an empty map serialised as "credit_postpaid_limit":{} the key
// would be an unknown property to that client, and if the field were required a
// payload without it would fail validation. Omitting it when empty keeps the
// payload legal for both, and a company with no postpaid grant is the common
// case, so this is the shape most payloads take.
func TestCreditPostpaidLimitOmittedWhenEmpty(t *testing.T) {
for name, postpaid := range map[string]map[string]*float64{
"nil map": nil,
"empty map": {},
} {
t.Run(name, func(t *testing.T) {
encoded, err := json.Marshal(rulesengine.Company{CreditPostpaidLimit: postpaid})
require.NoError(t, err)
assert.NotContains(t, string(encoded), "credit_postpaid_limit",
"an empty map must not put the key on the wire")
})
}

t.Run("a populated map is still sent", func(t *testing.T) {
limit := float64(100)
encoded, err := json.Marshal(rulesengine.Company{
CreditPostpaidLimit: map[string]*float64{"c": &limit},
})
require.NoError(t, err)
assert.Contains(t, string(encoded), `"credit_postpaid_limit":{"c":100}`)
})
}
48 changes: 24 additions & 24 deletions credit_overage_test.go → credit_postpaid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,34 @@ import (
"github.com/stretchr/testify/require"
)

// SCHX-582: credit overage. With overage enabled on a credit, consumption
// SCHX-582: credit postpaid. With postpaid 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
// These mirror credit_postpaid_limit_tests in rulesengine-rust; the two engines must
// agree (SCHY-515) for as long as both are in use.
func TestCreditOverage(t *testing.T) {
func TestCreditPostpaidLimit(t *testing.T) {
ctx := context.Background()

const creditID = "test-credit-id"

// overage: nil leaves the credit out of the map entirely (off); non-nil puts
// it in, with the pointed-at value as the cap — nil cap meaning uncapped.
companyWith := func(balance float64, overage *bool) *rulesengine.Company {
// postpaid: nil leaves the credit out of the map entirely (off); non-nil puts
// it in, with the pointed-at value as the cap — nil cap meaning unbounded.
companyWith := func(balance float64, postpaid *bool) *rulesengine.Company {
company := createTestCompany()
company.CreditBalances = map[string]float64{creditID: balance}
if overage != nil && *overage {
company.CreditOverage = map[string]*float64{creditID: nil}
} else if overage != nil {
company.CreditOverage = map[string]*float64{}
if postpaid != nil && *postpaid {
company.CreditPostpaidLimit = map[string]*float64{creditID: nil}
} else if postpaid != nil {
company.CreditPostpaidLimit = map[string]*float64{}
}
return company
}

companyWithCap := func(balance float64, limit float64) *rulesengine.Company {
enabled := true
company := companyWith(balance, &enabled)
company.CreditOverage = map[string]*float64{creditID: &limit}
company.CreditPostpaidLimit = map[string]*float64{creditID: &limit}
return company
}

Expand All @@ -63,7 +63,7 @@ func TestCreditOverage(t *testing.T) {
}

// The existing hard stop, unchanged when nobody has opted in.
t.Run("denies at zero without overage", func(t *testing.T) {
t.Run("denies at zero without postpaid", func(t *testing.T) {
assert.False(t, matches(t, companyWith(0, nil)))
})

Expand All @@ -72,11 +72,11 @@ func TestCreditOverage(t *testing.T) {
})

// The point of the feature.
t.Run("allows past zero with overage enabled", func(t *testing.T) {
t.Run("allows past zero with postpaid 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,
// A negative balance is legal (SCH-5103); postpaid 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))))
Expand All @@ -88,7 +88,7 @@ func TestCreditOverage(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.
// Postpaid 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
Expand All @@ -104,10 +104,10 @@ func TestCreditOverage(t *testing.T) {
Rule: rule,
})
require.NoError(t, err)
assert.False(t, result.Match, "the other credit has no overage and no balance")
assert.False(t, result.Match, "the other credit has no postpaid and no balance")
})

// Overage has to beat the more specific branches too. A caller-supplied
// Postpaid 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()
Expand Down Expand Up @@ -145,15 +145,15 @@ func TestCreditOverage(t *testing.T) {
t.Run("cap does not leak across credits", func(t *testing.T) {
company := companyWithCap(-140, 100)
otherCap := 100.0
company.CreditOverage = map[string]*float64{
company.CreditPostpaidLimit = map[string]*float64{
creditID: nil,
"other-credit": &otherCap,
}
assert.True(t, matches(t, company))
})

// Absent cap keeps the uncapped behaviour that shipped first.
t.Run("no cap means uncapped", func(t *testing.T) {
// Absent cap keeps the unbounded behaviour that shipped first.
t.Run("no cap means unbounded", func(t *testing.T) {
enabled := true
assert.True(t, matches(t, companyWith(-10_000, &enabled)))
})
Expand Down Expand Up @@ -191,15 +191,15 @@ func TestCreditOverage(t *testing.T) {
assert.True(t, matchesWithCost(t, companyWithCap(-50, 100), 50))
})

// Uncapped ignores the cost entirely — there is no floor to measure against.
t.Run("uncapped allows any cost", func(t *testing.T) {
// Unbounded ignores the cost entirely — there is no floor to measure against.
t.Run("unbounded allows any cost", func(t *testing.T) {
enabled := true
assert.True(t, matchesWithCost(t, companyWith(-10_000, &enabled), 5_000))
})

// Without overage the allowance is zero, so this stays the balance >= cost
// Without postpaid the allowance is zero, so this stays the balance >= cost
// check that has always applied.
t.Run("without overage a cost still gates on balance", func(t *testing.T) {
t.Run("without postpaid a cost still gates on balance", func(t *testing.T) {
assert.False(t, matchesWithCost(t, companyWith(10, nil), 50))
assert.True(t, matchesWithCost(t, companyWith(60, nil), 50))
})
Expand Down
Loading
Loading