@@ -16,15 +16,34 @@ import ( |
| 16 | 16 | |
| 17 | 17 | type Feature string |
| 18 | 18 | |
| 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. |
| 19 | 25 | 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 |
| 28 | 47 | ) |
| 29 | 48 | |
| 30 | 49 | type Limit string |
@@ -32,11 +51,58 @@ type Limit string |
| 32 | 51 | const ( |
| 33 | 52 | FreePrivateCollaborationLimit int64 = 3 |
| 34 | 53 | |
| 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 |
| 38 | 64 | ) |
| 39 | 65 | |
| 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 | + |
| 40 | 106 | type Reason string |
| 41 | 107 | |
| 42 | 108 | const ( |
@@ -82,10 +148,17 @@ type Loader struct { |
| 82 | 148 | deps Deps |
| 83 | 149 | } |
| 84 | 150 | |
| 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. |
| 85 | 156 | 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 |
| 89 | 162 | } |
| 90 | 163 | |
| 91 | 164 | var ( |
@@ -104,36 +177,77 @@ func ForOrg(ctx context.Context, deps Deps, orgID int64) (Set, error) { |
| 104 | 177 | } |
| 105 | 178 | |
| 106 | 179 | 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) { |
| 107 | 193 | if l.deps.Pool == nil { |
| 108 | 194 | return Set{}, ErrPoolRequired |
| 109 | 195 | } |
| 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 { |
| 115 | 197 | return Set{}, err |
| 116 | 198 | } |
| 117 | 199 | now := time.Now().UTC() |
| 118 | 200 | if l.deps.Now != nil { |
| 119 | 201 | now = l.deps.Now().UTC() |
| 120 | 202 | } |
| 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 | + } |
| 122 | 220 | } |
| 123 | 221 | |
| 124 | 222 | 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) { |
| 125 | 231 | if !KnownFeature(feature) { |
| 126 | 232 | return Decision{}, ErrUnknownFeature |
| 127 | 233 | } |
| 128 | | - set, err := ForOrg(ctx, deps, orgID) |
| 234 | + set, err := ForPrincipal(ctx, deps, p) |
| 129 | 235 | if err != nil { |
| 130 | 236 | return Decision{}, err |
| 131 | 237 | } |
| 132 | 238 | return set.CanUse(feature), nil |
| 133 | 239 | } |
| 134 | 240 | |
| 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. |
| 135 | 245 | 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) |
| 137 | 251 | } |
| 138 | 252 | |
| 139 | 253 | func (s Set) Limit(name Limit) (LimitValue, error) { |
@@ -172,8 +286,12 @@ func (s Set) Limit(name Limit) (LimitValue, error) { |
| 172 | 286 | return value, nil |
| 173 | 287 | } |
| 174 | 288 | |
| 289 | +// KnownFeature reports whether `feature` is in the registry. Used |
| 290 | +// by handler-side validation; PRO05 onwards prefers |
| 291 | +// FeatureAppliesToKind for kind-aware checks. |
| 175 | 292 | func KnownFeature(feature Feature) bool { |
| 176 | | - return requiredPlanForFeature(feature) != "" |
| 293 | + _, ok := featureKinds[feature] |
| 294 | + return ok |
| 177 | 295 | } |
| 178 | 296 | |
| 179 | 297 | func KnownLimit(name Limit) bool { |
@@ -188,10 +306,22 @@ func (d Decision) HTTPStatus() int { |
| 188 | 306 | return http.StatusPaymentRequired |
| 189 | 307 | } |
| 190 | 308 | |
| 309 | +// BillingPath returns the settings page URL for the orgSlug. For |
| 310 | +// PRO06+ user-tier upgrades, use UserBillingPath instead. |
| 191 | 311 | func (d Decision) BillingPath(orgSlug string) string { |
| 192 | 312 | return "/organizations/" + url.PathEscape(orgSlug) + "/settings/billing" |
| 193 | 313 | } |
| 194 | 314 | |
| 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. |
| 195 | 325 | func (d Decision) UpgradeBanner(label, orgSlug string) UpgradeBanner { |
| 196 | 326 | banner := UpgradeBanner{ |
| 197 | 327 | ActionText: "Manage billing and plans", |
@@ -209,39 +339,117 @@ func (d Decision) UpgradeBanner(label, orgSlug string) UpgradeBanner { |
| 209 | 339 | return banner |
| 210 | 340 | } |
| 211 | 341 | |
| 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) { |
| 224 | 371 | return "" |
| 225 | 372 | } |
| 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 "" |
| 226 | 389 | } |
| 227 | 390 | |
| 228 | 391 | func limitFeature(name Limit) (Feature, string, bool) { |
| 229 | 392 | 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 |
| 236 | 399 | default: |
| 237 | 400 | return "", "", false |
| 238 | 401 | } |
| 239 | 402 | } |
| 240 | 403 | |
| 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 { |
| 242 | 450 | decision := Decision{ |
| 243 | 451 | Feature: feature, |
| 244 | | - RequiredPlan: requiredPlanForFeature(feature), |
| 452 | + RequiredPlan: requiredPlanForFeature(feature, billing.SubjectKindOrg), |
| 245 | 453 | Reason: ReasonUpgradeRequired, |
| 246 | 454 | } |
| 247 | 455 | if decision.RequiredPlan == "" { |