tenseleyflow/shithub / 2c82cd2

Browse files

Harden org entitlement decisions

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
2c82cd28286b8ac350cdc90e66541e39dc816ba4
Parents
fd6b7b3
Tree
d9f0787

4 changed files

StatusFile+-
M docs/internal/billing.md 24 0
M docs/internal/permissions.md 7 0
M internal/entitlements/entitlements.go 164 18
M internal/entitlements/entitlements_test.go 135 31
docs/internal/billing.mdmodified
@@ -189,6 +189,13 @@ as scattered `orgs.plan` checks in handlers.
189
 store and scan the plan value, but product behavior should ask the
189
 store and scan the plan value, but product behavior should ask the
190
 entitlement package whether a feature key is available.
190
 entitlement package whether a feature key is available.
191
 
191
 
192
+The package entrypoint is `entitlements.ForOrg(ctx, deps, orgID)`,
193
+which loads the local `org_billing_states` projection and returns a
194
+request-scoped entitlement set. Callers then use `CanUse(feature)` for
195
+feature decisions and `Limit(name)` for paid limit metadata. The legacy
196
+`CheckOrgFeature` helper is a thin wrapper for handlers that need only
197
+one feature. These calls are deterministic and never call Stripe.
198
+
192
 Expected feature keys:
199
 Expected feature keys:
193
 
200
 
194
 - `org.secret_teams`
201
 - `org.secret_teams`
@@ -205,6 +212,23 @@ the policy permission and the paid entitlement for gated writes. Denials
205
 must preserve existing `policy.Maybe404` behavior where existence leaks
212
 must preserve existing `policy.Maybe404` behavior where existence leaks
206
 matter.
213
 matter.
207
 
214
 
215
+Entitlement outcomes are:
216
+
217
+- Free organizations receive `upgrade_required` for Team features.
218
+- Team organizations with `active` or `trialing` subscriptions receive
219
+  the feature.
220
+- Team organizations in `past_due` remain usable only while their local
221
+  grace window has not expired.
222
+- Team organizations with incomplete, unpaid, paused, canceled, or
223
+  expired-grace billing receive `billing_action_needed`.
224
+- Enterprise remains a contact-sales stub and receives
225
+  `enterprise_contact_sales` until a later Enterprise sprint defines a
226
+  real feature set.
227
+
228
+Handler upgrade helpers map paid-feature denials to HTTP 402 metadata
229
+and a billing-settings path, but handlers must still perform normal
230
+authorization first and preserve 404 masking for private resources.
231
+
208
 ## Downgrade behavior
232
 ## Downgrade behavior
209
 
233
 
210
 Downgrades must preserve customer data. Moving from Team to Free should
234
 Downgrades must preserve customer data. Moving from Team to Free should
docs/internal/permissions.mdmodified
@@ -131,6 +131,13 @@ That keeps security authorization independent from commercial product
131
 packaging, and makes downgrades/grace periods possible without
131
 packaging, and makes downgrades/grace periods possible without
132
 rewriting role checks.
132
 rewriting role checks.
133
 
133
 
134
+For paid-feature UI, handlers should use the entitlement decision's
135
+upgrade metadata instead of inventing per-surface billing state. A
136
+missing Team feature maps to HTTP 402 semantics and a billing-settings
137
+path after the normal authorization result has already been accepted.
138
+Enterprise is not implicitly Team-plus; it is a contact-sales
139
+entitlement result until the Enterprise product contract ships.
140
+
134
 ## Existence-leak guard
141
 ## Existence-leak guard
135
 
142
 
136
 `policy.Maybe404(decision, repo, actor)` maps a denial to a status
143
 `policy.Maybe404(decision, repo, actor)` maps a denial to a status
internal/entitlements/entitlements.gomodified
@@ -5,6 +5,8 @@ package entitlements
5
 import (
5
 import (
6
 	"context"
6
 	"context"
7
 	"errors"
7
 	"errors"
8
+	"net/http"
9
+	"net/url"
8
 	"time"
10
 	"time"
9
 
11
 
10
 	"github.com/jackc/pgx/v5/pgxpool"
12
 	"github.com/jackc/pgx/v5/pgxpool"
@@ -25,12 +27,22 @@ const (
25
 	FeatureOrgActionsMinutesQuota      Feature = "org.actions_minutes_quota"
27
 	FeatureOrgActionsMinutesQuota      Feature = "org.actions_minutes_quota"
26
 )
28
 )
27
 
29
 
30
+type Limit string
31
+
32
+const (
33
+	LimitOrgPrivateCollaboration Limit = "org.private_collaboration_limit"
34
+	LimitOrgStorageQuota         Limit = "org.storage_quota"
35
+	LimitOrgActionsMinutesQuota  Limit = "org.actions_minutes_quota"
36
+)
37
+
28
 type Reason string
38
 type Reason string
29
 
39
 
30
 const (
40
 const (
31
-	ReasonNone                Reason = ""
41
+	ReasonNone                   Reason = ""
32
-	ReasonUpgradeRequired     Reason = "upgrade_required"
42
+	ReasonUpgradeRequired        Reason = "upgrade_required"
33
-	ReasonBillingActionNeeded Reason = "billing_action_needed"
43
+	ReasonBillingActionNeeded    Reason = "billing_action_needed"
44
+	ReasonEnterpriseContactSales Reason = "enterprise_contact_sales"
45
+	ReasonUnknownFeature         Reason = "unknown_feature"
34
 )
46
 )
35
 
47
 
36
 type Deps struct {
48
 type Deps struct {
@@ -45,31 +57,150 @@ type Decision struct {
45
 	Reason       Reason
57
 	Reason       Reason
46
 }
58
 }
47
 
59
 
60
+type LimitValue struct {
61
+	Name         Limit
62
+	Feature      Feature
63
+	Allowed      bool
64
+	Defined      bool
65
+	Unlimited    bool
66
+	Value        int64
67
+	Unit         string
68
+	RequiredPlan billing.Plan
69
+	Reason       Reason
70
+}
71
+
72
+type UpgradeBanner struct {
73
+	Message    string
74
+	ActionText string
75
+	ActionHref string
76
+	StatusCode int
77
+}
78
+
79
+type Loader struct {
80
+	deps Deps
81
+}
82
+
83
+type Set struct {
84
+	OrgID int64
85
+	State billing.State
86
+	now   time.Time
87
+}
88
+
48
 var (
89
 var (
49
 	ErrPoolRequired   = errors.New("entitlements: pool is required")
90
 	ErrPoolRequired   = errors.New("entitlements: pool is required")
50
 	ErrOrgIDRequired  = errors.New("entitlements: org id is required")
91
 	ErrOrgIDRequired  = errors.New("entitlements: org id is required")
51
 	ErrUnknownFeature = errors.New("entitlements: unknown feature")
92
 	ErrUnknownFeature = errors.New("entitlements: unknown feature")
93
+	ErrUnknownLimit   = errors.New("entitlements: unknown limit")
52
 )
94
 )
53
 
95
 
54
-func CheckOrgFeature(ctx context.Context, deps Deps, orgID int64, feature Feature) (Decision, error) {
96
+func New(deps Deps) Loader {
55
-	if deps.Pool == nil {
97
+	return Loader{deps: deps}
56
-		return Decision{}, ErrPoolRequired
98
+}
99
+
100
+func ForOrg(ctx context.Context, deps Deps, orgID int64) (Set, error) {
101
+	return New(deps).ForOrg(ctx, orgID)
102
+}
103
+
104
+func (l Loader) ForOrg(ctx context.Context, orgID int64) (Set, error) {
105
+	if l.deps.Pool == nil {
106
+		return Set{}, ErrPoolRequired
57
 	}
107
 	}
58
 	if orgID == 0 {
108
 	if orgID == 0 {
59
-		return Decision{}, ErrOrgIDRequired
109
+		return Set{}, ErrOrgIDRequired
110
+	}
111
+	state, err := billing.GetOrgBillingState(ctx, billing.Deps{Pool: l.deps.Pool}, orgID)
112
+	if err != nil {
113
+		return Set{}, err
114
+	}
115
+	now := time.Now().UTC()
116
+	if l.deps.Now != nil {
117
+		now = l.deps.Now().UTC()
60
 	}
118
 	}
61
-	if requiredPlanForFeature(feature) == "" {
119
+	return Set{OrgID: orgID, State: state, now: now}, nil
120
+}
121
+
122
+func CheckOrgFeature(ctx context.Context, deps Deps, orgID int64, feature Feature) (Decision, error) {
123
+	if !KnownFeature(feature) {
62
 		return Decision{}, ErrUnknownFeature
124
 		return Decision{}, ErrUnknownFeature
63
 	}
125
 	}
64
-	state, err := billing.GetOrgBillingState(ctx, billing.Deps{Pool: deps.Pool}, orgID)
126
+	set, err := ForOrg(ctx, deps, orgID)
65
 	if err != nil {
127
 	if err != nil {
66
 		return Decision{}, err
128
 		return Decision{}, err
67
 	}
129
 	}
68
-	now := time.Now().UTC()
130
+	return set.CanUse(feature), nil
69
-	if deps.Now != nil {
131
+}
70
-		now = deps.Now().UTC()
132
+
133
+func (s Set) CanUse(feature Feature) Decision {
134
+	return decideFeature(s.now, s.State, feature)
135
+}
136
+
137
+func (s Set) Limit(name Limit) (LimitValue, error) {
138
+	feature, unit, ok := limitFeature(name)
139
+	if !ok {
140
+		return LimitValue{
141
+			Name:   name,
142
+			Reason: ReasonUnknownFeature,
143
+		}, ErrUnknownLimit
144
+	}
145
+	decision := s.CanUse(feature)
146
+	value := LimitValue{
147
+		Name:         name,
148
+		Feature:      feature,
149
+		Allowed:      decision.Allowed,
150
+		Unit:         unit,
151
+		RequiredPlan: decision.RequiredPlan,
152
+		Reason:       decision.Reason,
153
+	}
154
+	if !decision.Allowed {
155
+		return value, nil
156
+	}
157
+	switch name {
158
+	case LimitOrgPrivateCollaboration:
159
+		value.Defined = true
160
+		value.Unlimited = true
161
+	case LimitOrgStorageQuota, LimitOrgActionsMinutesQuota:
162
+		// SP08 owns usage accounting and concrete quota numbers. Until
163
+		// then, expose entitlement state without pretending metering is enforced.
164
+		value.Defined = false
165
+	}
166
+	return value, nil
167
+}
168
+
169
+func KnownFeature(feature Feature) bool {
170
+	return requiredPlanForFeature(feature) != ""
171
+}
172
+
173
+func KnownLimit(name Limit) bool {
174
+	_, _, ok := limitFeature(name)
175
+	return ok
176
+}
177
+
178
+func (d Decision) HTTPStatus() int {
179
+	if d.Allowed {
180
+		return http.StatusOK
181
+	}
182
+	return http.StatusPaymentRequired
183
+}
184
+
185
+func (d Decision) BillingPath(orgSlug string) string {
186
+	return "/organizations/" + url.PathEscape(orgSlug) + "/settings/billing"
187
+}
188
+
189
+func (d Decision) UpgradeBanner(label, orgSlug string) UpgradeBanner {
190
+	banner := UpgradeBanner{
191
+		ActionText: "Manage billing and plans",
192
+		ActionHref: d.BillingPath(orgSlug),
193
+		StatusCode: d.HTTPStatus(),
194
+	}
195
+	switch d.Reason {
196
+	case ReasonBillingActionNeeded:
197
+		banner.Message = label + " are read-only until Team billing is brought back into good standing."
198
+	case ReasonEnterpriseContactSales:
199
+		banner.Message = label + " require a supported enterprise plan. Contact sales to continue."
200
+	default:
201
+		banner.Message = label + " require Team billing. Upgrade this organization to continue."
71
 	}
202
 	}
72
-	return decideFeature(now, state, feature), nil
203
+	return banner
73
 }
204
 }
74
 
205
 
75
 func requiredPlanForFeature(feature Feature) billing.Plan {
206
 func requiredPlanForFeature(feature Feature) billing.Plan {
@@ -88,27 +219,42 @@ func requiredPlanForFeature(feature Feature) billing.Plan {
88
 	}
219
 	}
89
 }
220
 }
90
 
221
 
222
+func limitFeature(name Limit) (Feature, string, bool) {
223
+	switch name {
224
+	case LimitOrgPrivateCollaboration:
225
+		return FeatureOrgPrivateCollaboration, "collaborators", true
226
+	case LimitOrgStorageQuota:
227
+		return FeatureOrgStorageQuota, "bytes", true
228
+	case LimitOrgActionsMinutesQuota:
229
+		return FeatureOrgActionsMinutesQuota, "minutes", true
230
+	default:
231
+		return "", "", false
232
+	}
233
+}
234
+
91
 func decideFeature(now time.Time, state billing.State, feature Feature) Decision {
235
 func decideFeature(now time.Time, state billing.State, feature Feature) Decision {
92
 	decision := Decision{
236
 	decision := Decision{
93
 		Feature:      feature,
237
 		Feature:      feature,
94
 		RequiredPlan: requiredPlanForFeature(feature),
238
 		RequiredPlan: requiredPlanForFeature(feature),
95
 		Reason:       ReasonUpgradeRequired,
239
 		Reason:       ReasonUpgradeRequired,
96
 	}
240
 	}
241
+	if decision.RequiredPlan == "" {
242
+		decision.Reason = ReasonUnknownFeature
243
+		return decision
244
+	}
97
 	switch state.Plan {
245
 	switch state.Plan {
98
 	case billing.PlanEnterprise:
246
 	case billing.PlanEnterprise:
99
-		decision.Allowed = true
247
+		decision.Reason = ReasonEnterpriseContactSales
100
-		decision.Reason = ReasonNone
101
 		return decision
248
 		return decision
102
 	case billing.PlanTeam:
249
 	case billing.PlanTeam:
103
 		switch state.SubscriptionStatus {
250
 		switch state.SubscriptionStatus {
104
 		case billing.SubscriptionStatusActive,
251
 		case billing.SubscriptionStatusActive,
105
-			billing.SubscriptionStatusTrialing,
252
+			billing.SubscriptionStatusTrialing:
106
-			billing.SubscriptionStatusIncomplete:
107
 			decision.Allowed = true
253
 			decision.Allowed = true
108
 			decision.Reason = ReasonNone
254
 			decision.Reason = ReasonNone
109
 			return decision
255
 			return decision
110
 		case billing.SubscriptionStatusPastDue:
256
 		case billing.SubscriptionStatusPastDue:
111
-			if !state.GraceUntil.Valid || !now.After(state.GraceUntil.Time) {
257
+			if state.GraceUntil.Valid && !now.After(state.GraceUntil.Time) {
112
 				decision.Allowed = true
258
 				decision.Allowed = true
113
 				decision.Reason = ReasonNone
259
 				decision.Reason = ReasonNone
114
 				return decision
260
 				return decision
internal/entitlements/entitlements_test.gomodified
@@ -4,8 +4,11 @@ package entitlements_test
4
 
4
 
5
 import (
5
 import (
6
 	"context"
6
 	"context"
7
+	"errors"
7
 	"io"
8
 	"io"
8
 	"log/slog"
9
 	"log/slog"
10
+	"net/http"
11
+	"strings"
9
 	"testing"
12
 	"testing"
10
 	"time"
13
 	"time"
11
 
14
 
@@ -39,34 +42,31 @@ func TestCheckOrgFeature(t *testing.T) {
39
 		{
42
 		{
40
 			name: "team active allows feature",
43
 			name: "team active allows feature",
41
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
44
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
42
-				_, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
45
+				return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "active")
43
-					OrgID:                    orgID,
46
+			},
44
-					Plan:                     billing.PlanTeam,
47
+			want:   true,
45
-					Status:                   billing.SubscriptionStatusActive,
48
+			reason: entitlements.ReasonNone,
46
-					StripeSubscriptionID:     "sub_active",
49
+		},
47
-					StripeSubscriptionItemID: "si_active",
50
+		{
48
-					CurrentPeriodStart:       now,
51
+			name: "team trialing allows feature",
49
-					CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
52
+			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
50
-					LastWebhookEventID:       "evt_active",
53
+				return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusTrialing, "trialing")
51
-				})
52
-				return err
53
 			},
54
 			},
54
 			want:   true,
55
 			want:   true,
55
 			reason: entitlements.ReasonNone,
56
 			reason: entitlements.ReasonNone,
56
 		},
57
 		},
58
+		{
59
+			name: "team incomplete needs billing action",
60
+			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
61
+				return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusIncomplete, "incomplete")
62
+			},
63
+			want:   false,
64
+			reason: entitlements.ReasonBillingActionNeeded,
65
+		},
57
 		{
66
 		{
58
 			name: "team past due within grace still allows feature",
67
 			name: "team past due within grace still allows feature",
59
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
68
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
60
-				if _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
69
+				if err := setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "grace"); err != nil {
61
-					OrgID:                    orgID,
62
-					Plan:                     billing.PlanTeam,
63
-					Status:                   billing.SubscriptionStatusActive,
64
-					StripeSubscriptionID:     "sub_grace",
65
-					StripeSubscriptionItemID: "si_grace",
66
-					CurrentPeriodStart:       now,
67
-					CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
68
-					LastWebhookEventID:       "evt_active",
69
-				}); err != nil {
70
 					return err
70
 					return err
71
 				}
71
 				}
72
 				_, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due")
72
 				_, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due")
@@ -79,16 +79,7 @@ func TestCheckOrgFeature(t *testing.T) {
79
 		{
79
 		{
80
 			name: "team past due after grace needs billing action",
80
 			name: "team past due after grace needs billing action",
81
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
81
 			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
82
-				if _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
82
+				if err := setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "lapsed"); err != nil {
83
-					OrgID:                    orgID,
84
-					Plan:                     billing.PlanTeam,
85
-					Status:                   billing.SubscriptionStatusActive,
86
-					StripeSubscriptionID:     "sub_lapsed",
87
-					StripeSubscriptionItemID: "si_lapsed",
88
-					CurrentPeriodStart:       now,
89
-					CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
90
-					LastWebhookEventID:       "evt_active",
91
-				}); err != nil {
92
 					return err
83
 					return err
93
 				}
84
 				}
94
 				_, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due")
85
 				_, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due")
@@ -98,6 +89,22 @@ func TestCheckOrgFeature(t *testing.T) {
98
 			want:   false,
89
 			want:   false,
99
 			reason: entitlements.ReasonBillingActionNeeded,
90
 			reason: entitlements.ReasonBillingActionNeeded,
100
 		},
91
 		},
92
+		{
93
+			name: "team locked without grace needs billing action",
94
+			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
95
+				return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusPastDue, "locked")
96
+			},
97
+			want:   false,
98
+			reason: entitlements.ReasonBillingActionNeeded,
99
+		},
100
+		{
101
+			name: "enterprise stub does not unlock team features",
102
+			mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error {
103
+				return setSubscription(ctx, deps, orgID, now, billing.PlanEnterprise, billing.SubscriptionStatusActive, "enterprise")
104
+			},
105
+			want:   false,
106
+			reason: entitlements.ReasonEnterpriseContactSales,
107
+		},
101
 	}
108
 	}
102
 
109
 
103
 	for _, tt := range tests {
110
 	for _, tt := range tests {
@@ -130,6 +137,103 @@ func TestCheckOrgFeature(t *testing.T) {
130
 	}
137
 	}
131
 }
138
 }
132
 
139
 
140
+func TestForOrgCanUseAndLimit(t *testing.T) {
141
+	t.Parallel()
142
+	ctx := context.Background()
143
+	pool, orgID := setupEntitlementOrg(t)
144
+	now := time.Now().UTC().Truncate(time.Second)
145
+	if err := setSubscription(ctx, billing.Deps{Pool: pool}, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "limits"); err != nil {
146
+		t.Fatalf("set subscription: %v", err)
147
+	}
148
+
149
+	set, err := entitlements.ForOrg(ctx, entitlements.Deps{
150
+		Pool: pool,
151
+		Now:  func() time.Time { return now },
152
+	}, orgID)
153
+	if err != nil {
154
+		t.Fatalf("ForOrg: %v", err)
155
+	}
156
+	for _, feature := range []entitlements.Feature{
157
+		entitlements.FeatureOrgSecretTeams,
158
+		entitlements.FeatureOrgAdvancedBranchProtection,
159
+		entitlements.FeatureOrgRequiredReviewers,
160
+		entitlements.FeatureOrgActionsSecrets,
161
+		entitlements.FeatureOrgActionsVariables,
162
+		entitlements.FeatureOrgPrivateCollaboration,
163
+		entitlements.FeatureOrgStorageQuota,
164
+		entitlements.FeatureOrgActionsMinutesQuota,
165
+	} {
166
+		if decision := set.CanUse(feature); !decision.Allowed {
167
+			t.Fatalf("feature %s decision=%+v, want allowed", feature, decision)
168
+		}
169
+	}
170
+	collab, err := set.Limit(entitlements.LimitOrgPrivateCollaboration)
171
+	if err != nil {
172
+		t.Fatalf("Limit private collaboration: %v", err)
173
+	}
174
+	if !collab.Allowed || !collab.Defined || !collab.Unlimited || collab.Unit != "collaborators" {
175
+		t.Fatalf("private collaboration limit = %+v", collab)
176
+	}
177
+	storage, err := set.Limit(entitlements.LimitOrgStorageQuota)
178
+	if err != nil {
179
+		t.Fatalf("Limit storage: %v", err)
180
+	}
181
+	if !storage.Allowed || storage.Defined || storage.Unit != "bytes" {
182
+		t.Fatalf("storage limit = %+v, want allowed but deferred concrete quota", storage)
183
+	}
184
+}
185
+
186
+func TestUnknownFeatureAndLimit(t *testing.T) {
187
+	t.Parallel()
188
+	ctx := context.Background()
189
+	pool, orgID := setupEntitlementOrg(t)
190
+	_, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: pool}, orgID, entitlements.Feature("org.mystery"))
191
+	if !errors.Is(err, entitlements.ErrUnknownFeature) {
192
+		t.Fatalf("CheckOrgFeature unknown err=%v, want ErrUnknownFeature", err)
193
+	}
194
+	set, err := entitlements.ForOrg(ctx, entitlements.Deps{Pool: pool}, orgID)
195
+	if err != nil {
196
+		t.Fatalf("ForOrg: %v", err)
197
+	}
198
+	_, err = set.Limit(entitlements.Limit("org.mystery_limit"))
199
+	if !errors.Is(err, entitlements.ErrUnknownLimit) {
200
+		t.Fatalf("Limit unknown err=%v, want ErrUnknownLimit", err)
201
+	}
202
+}
203
+
204
+func TestDecisionUpgradeBanner(t *testing.T) {
205
+	t.Parallel()
206
+	decision := entitlements.Decision{
207
+		Feature:      entitlements.FeatureOrgSecretTeams,
208
+		RequiredPlan: billing.PlanTeam,
209
+		Reason:       entitlements.ReasonUpgradeRequired,
210
+	}
211
+	banner := decision.UpgradeBanner("Secret teams", "acme inc")
212
+	if banner.StatusCode != http.StatusPaymentRequired {
213
+		t.Fatalf("status=%d, want 402", banner.StatusCode)
214
+	}
215
+	if banner.ActionHref != "/organizations/acme%20inc/settings/billing" {
216
+		t.Fatalf("href=%q", banner.ActionHref)
217
+	}
218
+	if !strings.Contains(banner.Message, "require Team billing") {
219
+		t.Fatalf("message=%q", banner.Message)
220
+	}
221
+}
222
+
223
+func setSubscription(ctx context.Context, deps billing.Deps, orgID int64, now time.Time, plan billing.Plan, status billing.SubscriptionStatus, suffix string) error {
224
+	_, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{
225
+		OrgID:                    orgID,
226
+		Plan:                     plan,
227
+		Status:                   status,
228
+		StripeSubscriptionID:     "sub_" + suffix,
229
+		StripeSubscriptionItemID: "si_" + suffix,
230
+		CurrentPeriodStart:       now,
231
+		CurrentPeriodEnd:         now.Add(30 * 24 * time.Hour),
232
+		LastWebhookEventID:       "evt_" + suffix,
233
+	})
234
+	return err
235
+}
236
+
133
 func setupEntitlementOrg(t *testing.T) (*pgxpool.Pool, int64) {
237
 func setupEntitlementOrg(t *testing.T) (*pgxpool.Pool, int64) {
134
 	t.Helper()
238
 	t.Helper()
135
 	pool := dbtest.NewTestDB(t)
239
 	pool := dbtest.NewTestDB(t)