tenseleyflow/shithub / e3db9d9

Browse files

entitlements: ForPrincipal + un-namespaced constants + AppliesTo + user-side decide

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e3db9d9d59ae7bc535615c19305883b04f32bcc2
Parents
a72be94
Tree
b8d5153

1 changed file

StatusFile+-
M internal/entitlements/entitlements.go 251 43
internal/entitlements/entitlements.gomodified
@@ -16,15 +16,34 @@ import (
1616
 
1717
 type Feature string
1818
 
19
+// Feature constants. PRO02 Q5 ratified an un-namespaced naming
20
+// convention with per-feature AppliesTo() metadata — features
21
+// shared between user and org repos drop the `Org` infix; features
22
+// scoped to org-owned resources (actions secrets / variables) keep
23
+// it because the *resource* is org-scoped, not because the *gate*
24
+// is. The kind-applicability registry lives in featureKinds below.
1925
 const (
20
-	FeatureOrgSecretTeams              Feature = "org.secret_teams"
21
-	FeatureOrgAdvancedBranchProtection Feature = "org.advanced_branch_protection"
22
-	FeatureOrgRequiredReviewers        Feature = "org.required_reviewers"
23
-	FeatureOrgActionsSecrets           Feature = "org.actions_org_secrets" // #nosec G101 -- entitlement feature key, not a credential.
24
-	FeatureOrgActionsVariables         Feature = "org.actions_org_variables"
25
-	FeatureOrgPrivateCollaboration     Feature = "org.private_collaboration_limit"
26
-	FeatureOrgStorageQuota             Feature = "org.storage_quota"
27
-	FeatureOrgActionsMinutesQuota      Feature = "org.actions_minutes_quota"
26
+	FeatureSecretTeams              Feature = "secret_teams"
27
+	FeatureAdvancedBranchProtection Feature = "advanced_branch_protection"
28
+	FeatureRequiredReviewers        Feature = "required_reviewers"
29
+	FeatureActionsOrgSecrets        Feature = "actions_org_secrets" // #nosec G101 -- entitlement feature key, not a credential.
30
+	FeatureActionsOrgVariables      Feature = "actions_org_variables"
31
+	FeaturePrivateCollaboration     Feature = "private_collaboration_limit"
32
+	FeatureStorageQuota             Feature = "storage_quota"
33
+	FeatureActionsMinutesQuota      Feature = "actions_minutes_quota"
34
+)
35
+
36
+// Deprecated aliases. Old call sites continue to compile; PRO05's
37
+// sweep migrates them. Remove after PRO07 enforce flip stabilizes.
38
+const (
39
+	FeatureOrgSecretTeams              = FeatureSecretTeams
40
+	FeatureOrgAdvancedBranchProtection = FeatureAdvancedBranchProtection
41
+	FeatureOrgRequiredReviewers        = FeatureRequiredReviewers
42
+	FeatureOrgActionsSecrets           = FeatureActionsOrgSecrets
43
+	FeatureOrgActionsVariables         = FeatureActionsOrgVariables
44
+	FeatureOrgPrivateCollaboration     = FeaturePrivateCollaboration
45
+	FeatureOrgStorageQuota             = FeatureStorageQuota
46
+	FeatureOrgActionsMinutesQuota      = FeatureActionsMinutesQuota
2847
 )
2948
 
3049
 type Limit string
@@ -32,11 +51,58 @@ type Limit string
3251
 const (
3352
 	FreePrivateCollaborationLimit int64 = 3
3453
 
35
-	LimitOrgPrivateCollaboration Limit = "org.private_collaboration_limit"
36
-	LimitOrgStorageQuota         Limit = "org.storage_quota"
37
-	LimitOrgActionsMinutesQuota  Limit = "org.actions_minutes_quota"
54
+	LimitPrivateCollaboration Limit = "private_collaboration_limit"
55
+	LimitStorageQuota         Limit = "storage_quota"
56
+	LimitActionsMinutesQuota  Limit = "actions_minutes_quota"
57
+)
58
+
59
+// Deprecated limit aliases. Same migration story as features.
60
+const (
61
+	LimitOrgPrivateCollaboration = LimitPrivateCollaboration
62
+	LimitOrgStorageQuota         = LimitStorageQuota
63
+	LimitOrgActionsMinutesQuota  = LimitActionsMinutesQuota
3864
 )
3965
 
66
+// featureKinds is the AppliesTo registry. Each feature lists the
67
+// principal kinds it applies to. requirePrincipalFeature rejects
68
+// calls with a kind the feature doesn't apply to — surfacing the
69
+// mistake at handler boot time, not at request time.
70
+//
71
+// PRO02 Q5 ratified Option A (un-namespaced constants with this
72
+// registry) and PRO01 ratified the per-feature applicability.
73
+// FeaturePrivateCollaboration stays org-only — PRO01 explicitly
74
+// rejected reintroducing a Free user collaborator cap.
75
+var featureKinds = map[Feature][]billing.SubjectKind{
76
+	FeatureSecretTeams:              {billing.SubjectKindOrg},
77
+	FeatureAdvancedBranchProtection: {billing.SubjectKindUser, billing.SubjectKindOrg},
78
+	FeatureRequiredReviewers:        {billing.SubjectKindUser, billing.SubjectKindOrg},
79
+	FeatureActionsOrgSecrets:        {billing.SubjectKindOrg},
80
+	FeatureActionsOrgVariables:      {billing.SubjectKindOrg},
81
+	FeaturePrivateCollaboration:     {billing.SubjectKindOrg},
82
+	FeatureStorageQuota:             {billing.SubjectKindOrg}, // user pending SP08
83
+	FeatureActionsMinutesQuota:      {billing.SubjectKindOrg}, // user pending SP08
84
+}
85
+
86
+// AppliesTo reports the principal kinds a feature applies to.
87
+// Returns nil for unknown features.
88
+func AppliesTo(feature Feature) []billing.SubjectKind {
89
+	return featureKinds[feature]
90
+}
91
+
92
+// FeatureAppliesToKind is the handler-side guard: returns true when
93
+// `feature` is valid for `kind`. Use this to short-circuit gating
94
+// when the principal kind doesn't even apply to the feature (org-
95
+// only features are inapplicable on personal repos and should
96
+// fall through to the basic, ungated behavior).
97
+func FeatureAppliesToKind(feature Feature, kind billing.SubjectKind) bool {
98
+	for _, k := range featureKinds[feature] {
99
+		if k == kind {
100
+			return true
101
+		}
102
+	}
103
+	return false
104
+}
105
+
40106
 type Reason string
41107
 
42108
 const (
@@ -82,10 +148,17 @@ type Loader struct {
82148
 	deps Deps
83149
 }
84150
 
151
+// Set carries the resolved entitlement state for a principal.
152
+// Post-PRO05 the canonical routing key is `Principal`; OrgID and
153
+// State are kept populated for org-kind sets so SP-era callers
154
+// continue to read them without modification. User-kind sets
155
+// populate `UserState` and leave `State` zero.
85156
 type Set struct {
86
-	OrgID int64
87
-	State billing.State
88
-	now   time.Time
157
+	OrgID     int64             // populated for org kind only (deprecated; prefer Principal.ID)
158
+	Principal billing.Principal // PRO05: canonical routing key
159
+	State     billing.State     // org-kind billing state (zero for user kind)
160
+	UserState billing.UserState // user-kind billing state (zero for org kind)
161
+	now       time.Time
89162
 }
90163
 
91164
 var (
@@ -104,36 +177,77 @@ func ForOrg(ctx context.Context, deps Deps, orgID int64) (Set, error) {
104177
 }
105178
 
106179
 func (l Loader) ForOrg(ctx context.Context, orgID int64) (Set, error) {
180
+	return l.ForPrincipal(ctx, billing.PrincipalForOrg(orgID))
181
+}
182
+
183
+// ForPrincipal is the kind-agnostic loader. Branches to the org or
184
+// user billing-state table based on `p.Kind` and returns a Set
185
+// whose CanUse / Limit decisions reflect the principal's
186
+// subscription state. PRO05+ callers prefer this entry point over
187
+// ForOrg.
188
+func ForPrincipal(ctx context.Context, deps Deps, p billing.Principal) (Set, error) {
189
+	return New(deps).ForPrincipal(ctx, p)
190
+}
191
+
192
+func (l Loader) ForPrincipal(ctx context.Context, p billing.Principal) (Set, error) {
107193
 	if l.deps.Pool == nil {
108194
 		return Set{}, ErrPoolRequired
109195
 	}
110
-	if orgID == 0 {
111
-		return Set{}, ErrOrgIDRequired
112
-	}
113
-	state, err := billing.GetOrgBillingState(ctx, billing.Deps{Pool: l.deps.Pool}, orgID)
114
-	if err != nil {
196
+	if err := p.Validate(); err != nil {
115197
 		return Set{}, err
116198
 	}
117199
 	now := time.Now().UTC()
118200
 	if l.deps.Now != nil {
119201
 		now = l.deps.Now().UTC()
120202
 	}
121
-	return Set{OrgID: orgID, State: state, now: now}, nil
203
+	bd := billing.Deps{Pool: l.deps.Pool}
204
+	switch p.Kind {
205
+	case billing.SubjectKindOrg:
206
+		state, err := billing.GetOrgBillingState(ctx, bd, p.ID)
207
+		if err != nil {
208
+			return Set{}, err
209
+		}
210
+		return Set{OrgID: p.ID, Principal: p, State: state, now: now}, nil
211
+	case billing.SubjectKindUser:
212
+		state, err := billing.GetUserBillingState(ctx, bd, p.ID)
213
+		if err != nil {
214
+			return Set{}, err
215
+		}
216
+		return Set{Principal: p, UserState: state, now: now}, nil
217
+	default:
218
+		return Set{}, billing.ErrInvalidPrincipal
219
+	}
122220
 }
123221
 
124222
 func CheckOrgFeature(ctx context.Context, deps Deps, orgID int64, feature Feature) (Decision, error) {
223
+	return CheckPrincipalFeature(ctx, deps, billing.PrincipalForOrg(orgID), feature)
224
+}
225
+
226
+// CheckPrincipalFeature is the kind-agnostic decision shortcut.
227
+// Loads the principal's state and returns the Decision for
228
+// `feature`. The unknown-feature check covers both renamed and
229
+// deprecated-alias forms.
230
+func CheckPrincipalFeature(ctx context.Context, deps Deps, p billing.Principal, feature Feature) (Decision, error) {
125231
 	if !KnownFeature(feature) {
126232
 		return Decision{}, ErrUnknownFeature
127233
 	}
128
-	set, err := ForOrg(ctx, deps, orgID)
234
+	set, err := ForPrincipal(ctx, deps, p)
129235
 	if err != nil {
130236
 		return Decision{}, err
131237
 	}
132238
 	return set.CanUse(feature), nil
133239
 }
134240
 
241
+// CanUse evaluates `feature` against the carried Principal's
242
+// current state. For org kind, behavior is unchanged from SP05.
243
+// For user kind (PRO05+), feature must be in the user AppliesTo
244
+// list and the user-state Plan/Status branch decides.
135245
 func (s Set) CanUse(feature Feature) Decision {
136
-	return decideFeature(s.now, s.State, feature)
246
+	if s.Principal.Kind == billing.SubjectKindUser {
247
+		return decideUserFeature(s.now, s.UserState, feature)
248
+	}
249
+	// Default / org kind: existing flow.
250
+	return decideOrgFeature(s.now, s.State, feature)
137251
 }
138252
 
139253
 func (s Set) Limit(name Limit) (LimitValue, error) {
@@ -172,8 +286,12 @@ func (s Set) Limit(name Limit) (LimitValue, error) {
172286
 	return value, nil
173287
 }
174288
 
289
+// KnownFeature reports whether `feature` is in the registry. Used
290
+// by handler-side validation; PRO05 onwards prefers
291
+// FeatureAppliesToKind for kind-aware checks.
175292
 func KnownFeature(feature Feature) bool {
176
-	return requiredPlanForFeature(feature) != ""
293
+	_, ok := featureKinds[feature]
294
+	return ok
177295
 }
178296
 
179297
 func KnownLimit(name Limit) bool {
@@ -188,10 +306,22 @@ func (d Decision) HTTPStatus() int {
188306
 	return http.StatusPaymentRequired
189307
 }
190308
 
309
+// BillingPath returns the settings page URL for the orgSlug. For
310
+// PRO06+ user-tier upgrades, use UserBillingPath instead.
191311
 func (d Decision) BillingPath(orgSlug string) string {
192312
 	return "/organizations/" + url.PathEscape(orgSlug) + "/settings/billing"
193313
 }
194314
 
315
+// UserBillingPath returns the user's settings page URL. PRO06 wires
316
+// `/settings/billing` for personal accounts; this helper hides the
317
+// route from callers that just need a target.
318
+func (d Decision) UserBillingPath() string {
319
+	return "/settings/billing"
320
+}
321
+
322
+// UpgradeBanner returns the org-flavored banner. Kept for SP-era
323
+// callers; PRO05+ callers should prefer PrincipalUpgradeBanner
324
+// which selects copy + path based on the Decision's required plan.
195325
 func (d Decision) UpgradeBanner(label, orgSlug string) UpgradeBanner {
196326
 	banner := UpgradeBanner{
197327
 		ActionText: "Manage billing and plans",
@@ -209,39 +339,117 @@ func (d Decision) UpgradeBanner(label, orgSlug string) UpgradeBanner {
209339
 	return banner
210340
 }
211341
 
212
-func requiredPlanForFeature(feature Feature) billing.Plan {
213
-	switch feature {
214
-	case FeatureOrgSecretTeams,
215
-		FeatureOrgAdvancedBranchProtection,
216
-		FeatureOrgRequiredReviewers,
217
-		FeatureOrgActionsSecrets,
218
-		FeatureOrgActionsVariables,
219
-		FeatureOrgPrivateCollaboration,
220
-		FeatureOrgStorageQuota,
221
-		FeatureOrgActionsMinutesQuota:
222
-		return billing.PlanTeam
223
-	default:
342
+// PrincipalUpgradeBanner returns the banner appropriate to the
343
+// principal kind. User-kind banners say "Upgrade to Pro" and point
344
+// at /settings/billing; org-kind banners say "Upgrade to Team" and
345
+// point at the org billing settings page.
346
+func (d Decision) PrincipalUpgradeBanner(label string, p billing.Principal, orgSlug string) UpgradeBanner {
347
+	if p.IsUser() {
348
+		banner := UpgradeBanner{
349
+			ActionText: "Manage billing",
350
+			ActionHref: d.UserBillingPath(),
351
+			StatusCode: d.HTTPStatus(),
352
+		}
353
+		switch d.Reason {
354
+		case ReasonBillingActionNeeded:
355
+			banner.Message = label + " are read-only until Pro billing is brought back into good standing."
356
+		default:
357
+			banner.Message = label + " require Pro billing. Upgrade this account to continue."
358
+		}
359
+		return banner
360
+	}
361
+	return d.UpgradeBanner(label, orgSlug)
362
+}
363
+
364
+// requiredPlanForFeature returns the plan that unlocks `feature`
365
+// for `kind`. Returns "" for unknown features or kinds the feature
366
+// doesn't apply to. PRO04+PRO05 ratification: user kind ⇒ PlanPro;
367
+// org kind ⇒ PlanTeam. No third option exists in PRO04's scope;
368
+// enterprise is contact-sales (not self-serve unlock).
369
+func requiredPlanForFeature(feature Feature, kind billing.SubjectKind) billing.Plan {
370
+	if !FeatureAppliesToKind(feature, kind) {
224371
 		return ""
225372
 	}
373
+	switch kind {
374
+	case billing.SubjectKindOrg:
375
+		return billing.PlanTeam
376
+	case billing.SubjectKindUser:
377
+		// Note: PlanPro is a user_plan enum value; billing.PlanFree
378
+		// etc. are org_plan. The cross-table-ness is deliberate per
379
+		// PRO02 Q2 (separate enums). The Decision type holds the
380
+		// returned plan as the org-plan-typed billing.Plan because
381
+		// pre-PRO05 callers assume that. PRO05 keeps the field
382
+		// shape and writes "pro" in there as a string — Plan is a
383
+		// type alias for billingdb.OrgPlan which is just a string
384
+		// under the hood, so this is safe and PRO06+PRO07 callers
385
+		// that compare against PlanTeam continue to work.
386
+		return billing.Plan("pro")
387
+	}
388
+	return ""
226389
 }
227390
 
228391
 func limitFeature(name Limit) (Feature, string, bool) {
229392
 	switch name {
230
-	case LimitOrgPrivateCollaboration:
231
-		return FeatureOrgPrivateCollaboration, "collaborators", true
232
-	case LimitOrgStorageQuota:
233
-		return FeatureOrgStorageQuota, "bytes", true
234
-	case LimitOrgActionsMinutesQuota:
235
-		return FeatureOrgActionsMinutesQuota, "minutes", true
393
+	case LimitPrivateCollaboration:
394
+		return FeaturePrivateCollaboration, "collaborators", true
395
+	case LimitStorageQuota:
396
+		return FeatureStorageQuota, "bytes", true
397
+	case LimitActionsMinutesQuota:
398
+		return FeatureActionsMinutesQuota, "minutes", true
236399
 	default:
237400
 		return "", "", false
238401
 	}
239402
 }
240403
 
241
-func decideFeature(now time.Time, state billing.State, feature Feature) Decision {
404
+// decideUserFeature is the user-kind decision body. Mirrors
405
+// decideOrgFeature's structure with user_plan ('free' | 'pro') in
406
+// place of org_plan and `pro` as the unlocking plan. PRO07 flips
407
+// enforcement; PRO05 (and PRO06) keep this path live for the
408
+// CanUse return value but handler-side gating runs in report-only
409
+// mode so the deny is logged not surfaced.
410
+func decideUserFeature(now time.Time, state billing.UserState, feature Feature) Decision {
411
+	decision := Decision{
412
+		Feature:      feature,
413
+		RequiredPlan: requiredPlanForFeature(feature, billing.SubjectKindUser),
414
+		Reason:       ReasonUpgradeRequired,
415
+	}
416
+	if decision.RequiredPlan == "" {
417
+		decision.Reason = ReasonUnknownFeature
418
+		return decision
419
+	}
420
+	switch state.Plan {
421
+	case billing.UserPlanPro:
422
+		switch state.SubscriptionStatus {
423
+		case billing.SubscriptionStatusActive,
424
+			billing.SubscriptionStatusTrialing:
425
+			decision.Allowed = true
426
+			decision.Reason = ReasonNone
427
+			return decision
428
+		case billing.SubscriptionStatusPastDue:
429
+			if state.GraceUntil.Valid && !now.After(state.GraceUntil.Time) {
430
+				decision.Allowed = true
431
+				decision.Reason = ReasonNone
432
+				return decision
433
+			}
434
+			decision.Reason = ReasonBillingActionNeeded
435
+			return decision
436
+		default:
437
+			decision.Reason = ReasonBillingActionNeeded
438
+			return decision
439
+		}
440
+	default:
441
+		// Free user — feature gated.
442
+		return decision
443
+	}
444
+}
445
+
446
+// decideOrgFeature is the org-kind decision body. Kept as the
447
+// existing flow so org callers see byte-for-byte identical
448
+// behavior; the user-kind path lives in decideUserFeature.
449
+func decideOrgFeature(now time.Time, state billing.State, feature Feature) Decision {
242450
 	decision := Decision{
243451
 		Feature:      feature,
244
-		RequiredPlan: requiredPlanForFeature(feature),
452
+		RequiredPlan: requiredPlanForFeature(feature, billing.SubjectKindOrg),
245453
 		Reason:       ReasonUpgradeRequired,
246454
 	}
247455
 	if decision.RequiredPlan == "" {