Document private collaboration limits
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
c6d6c1673123bec635f609a13435449cafce0112- Parents
-
09c93d2 - Tree
108d760
c6d6c16
c6d6c1673123bec635f609a13435449cafce011209c93d2
108d760| Status | File | + | - |
|---|---|---|---|
| M |
docs/internal/billing.md
|
23 | 3 |
| M |
internal/orgs/private_collaboration_test.go
|
42 | 0 |
| M |
internal/web/handlers/orgs/billing_settings.go
|
32 | 0 |
| M |
internal/web/templates/orgs/settings_billing.html
|
11 | 0 |
docs/internal/billing.mdmodified@@ -69,6 +69,7 @@ Rules for paid-org copy: | ||
| 69 | 69 | | Public org repositories | Included | Included | Contact sales | |
| 70 | 70 | | Basic private org repositories | Included | Included | Contact sales | |
| 71 | 71 | | Org members and invitations | Included | Billed by active member | Contact sales | |
| 72 | +| Effective private org collaborators | Up to 3 | Unlimited while active/in grace | Contact sales | | |
| 72 | 73 | | Visible teams | Included | Included | Contact sales | |
| 73 | 74 | | Secret teams | Upgrade | Included | Contact sales | |
| 74 | 75 | | Basic branch protection | Included | Included | Contact sales | |
@@ -224,6 +225,28 @@ PAYMENTS SP07 completes the first self-serve billing settings surface: | ||
| 224 | 225 | subscription, or subscription-item IDs. Site admins see a debug panel |
| 225 | 226 | with those IDs and the latest locally recorded webhook receipt state. |
| 226 | 227 | |
| 228 | +PAYMENTS SP06a adds the first private-collaboration limit: | |
| 229 | + | |
| 230 | +- Free organizations may have up to 3 unique humans with effective | |
| 231 | + access to at least one private organization repository. | |
| 232 | +- Team organizations with active, trialing, or in-grace subscriptions | |
| 233 | + have unlimited private collaborators. | |
| 234 | +- The effective private-collaborator set counts org owners, direct | |
| 235 | + collaborators on private org repos, and team members who inherit a | |
| 236 | + private repo grant through direct team membership or one-level parent | |
| 237 | + team inheritance. Plain org members do not count unless they gain | |
| 238 | + private repo access through one of those paths. | |
| 239 | +- Public repository collaboration never counts toward the limit. | |
| 240 | +- Downgrades preserve existing access even when the org is already over | |
| 241 | + the Free limit, but writes that add a new effective private | |
| 242 | + collaborator are blocked until the org upgrades or removes access. | |
| 243 | +- Creating/importing a private org repository and changing an org repo | |
| 244 | + from public to private are blocked on Free when the resulting private | |
| 245 | + collaborator set would exceed the limit. | |
| 246 | +- Cleanup writes remain available: removing org members, team members, | |
| 247 | + direct collaborators, team repo grants, and gated configuration must | |
| 248 | + not require Team. | |
| 249 | + | |
| 227 | 250 | ## Entitlement architecture |
| 228 | 251 | |
| 229 | 252 | Paid feature checks must live behind a central entitlement package, not |
@@ -283,9 +306,6 @@ organization upgrades again. | ||
| 283 | 306 | |
| 284 | 307 | ## Open questions for implementation |
| 285 | 308 | |
| 286 | -- Whether Free should limit private org collaborators before usage | |
| 287 | - metering exists, or whether the first paid gates are advanced controls | |
| 288 | - only. | |
| 289 | 309 | - Exact Free and Team quota numbers for Actions and storage. These must |
| 290 | 310 | come from real host-cost estimates before SP08. |
| 291 | 311 | |
internal/orgs/private_collaboration_test.gomodified@@ -73,6 +73,48 @@ func TestTeamExpansionRespectsPrivateCollaborationLimit(t *testing.T) { | ||
| 73 | 73 | } |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | +func TestOwnerInvitationsRespectPrivateCollaborationLimitAtSendAndAccept(t *testing.T) { | |
| 77 | + pool, deps, alice := setup(t) | |
| 78 | + ctx := context.Background() | |
| 79 | + org, err := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice}) | |
| 80 | + if err != nil { | |
| 81 | + t.Fatalf("create org: %v", err) | |
| 82 | + } | |
| 83 | + repo := mustOrgRepo(t, pool, org.ID, "secret", "private") | |
| 84 | + insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "bob")) | |
| 85 | + insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "carol")) | |
| 86 | + | |
| 87 | + dave := mustUser(t, pool, "dave") | |
| 88 | + if _, err := orgs.Invite(ctx, deps, orgs.InviteParams{ | |
| 89 | + OrgID: org.ID, | |
| 90 | + InvitedByUserID: alice, | |
| 91 | + TargetUsername: "dave", | |
| 92 | + Role: "owner", | |
| 93 | + }); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 94 | + t.Fatalf("Invite owner err=%v, want private collaboration limit", err) | |
| 95 | + } | |
| 96 | + | |
| 97 | + if _, err := pool.Exec(ctx, `DELETE FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2`, repo.ID, dave); err != nil { | |
| 98 | + t.Fatalf("cleanup accidental dave collab: %v", err) | |
| 99 | + } | |
| 100 | + if _, err := pool.Exec(ctx, `DELETE FROM repo_collaborators WHERE repo_id = $1 AND user_id = (SELECT id FROM users WHERE username = 'carol')`, repo.ID); err != nil { | |
| 101 | + t.Fatalf("remove carol collab: %v", err) | |
| 102 | + } | |
| 103 | + res, err := orgs.Invite(ctx, deps, orgs.InviteParams{ | |
| 104 | + OrgID: org.ID, | |
| 105 | + InvitedByUserID: alice, | |
| 106 | + TargetUsername: "dave", | |
| 107 | + Role: "owner", | |
| 108 | + }) | |
| 109 | + if err != nil { | |
| 110 | + t.Fatalf("Invite owner under limit: %v", err) | |
| 111 | + } | |
| 112 | + insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "erin")) | |
| 113 | + if err := orgs.AcceptInvitation(ctx, deps, res.Invitation, dave); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) { | |
| 114 | + t.Fatalf("AcceptInvitation err=%v, want private collaboration limit", err) | |
| 115 | + } | |
| 116 | +} | |
| 117 | + | |
| 76 | 118 | func mustOrgRepo(t *testing.T, db reposdb.DBTX, orgID int64, name, visibility string) reposdb.Repo { |
| 77 | 119 | t.Helper() |
| 78 | 120 | repo, err := reposdb.New().CreateRepo(context.Background(), db, reposdb.CreateRepoParams{ |
internal/web/handlers/orgs/billing_settings.gomodified@@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | orgbilling "github.com/tenseleyFlow/shithub/internal/billing" |
| 17 | 17 | billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc" |
| 18 | 18 | "github.com/tenseleyFlow/shithub/internal/billing/stripebilling" |
| 19 | + "github.com/tenseleyFlow/shithub/internal/entitlements" | |
| 19 | 20 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 20 | 21 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 21 | 22 | ) |
@@ -44,6 +45,12 @@ type billingSeatBreakdown struct { | ||
| 44 | 45 | SnapshotLabel string |
| 45 | 46 | } |
| 46 | 47 | |
| 48 | +type billingPrivateCollaborationBreakdown struct { | |
| 49 | + Count int64 | |
| 50 | + LimitLabel string | |
| 51 | + Detail string | |
| 52 | +} | |
| 53 | + | |
| 47 | 54 | type billingAlert struct { |
| 48 | 55 | Class string |
| 49 | 56 | Message string |
@@ -204,6 +211,7 @@ func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request, | ||
| 204 | 211 | if err != nil { |
| 205 | 212 | h.d.Logger.WarnContext(r.Context(), "org billing: count pending invitations", "org_id", org.ID, "error", err) |
| 206 | 213 | } |
| 214 | + privateCollab := h.billingPrivateCollaborationBreakdown(r, org.ID) | |
| 207 | 215 | invoices, err := orgbilling.ListInvoicesForOrg(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, 10) |
| 208 | 216 | if err != nil { |
| 209 | 217 | h.d.Logger.WarnContext(r.Context(), "org billing: list invoices", "org_id", org.ID, "error", err) |
@@ -227,6 +235,7 @@ func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request, | ||
| 227 | 235 | "BillingAlert": billingAlertForState(state, org.Slug), |
| 228 | 236 | "Summary": billingSummary(state, memberCount), |
| 229 | 237 | "Seats": billingSeatBreakdown{ActiveMembers: memberCount, BillableSeats: int64(state.BillableSeats), PendingInvites: pendingInviteCount, SnapshotLabel: billingSeatDetail(state)}, |
| 238 | + "PrivateCollaboration": privateCollab, | |
| 230 | 239 | "CanStartCheckout": h.billingConfigured(), |
| 231 | 240 | "CanManageSubscription": h.billingConfigured() && state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "", |
| 232 | 241 | "GracePeriodLabel": formatGracePeriod(h.d.BillingGracePeriod), |
@@ -236,6 +245,29 @@ func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request, | ||
| 236 | 245 | }) |
| 237 | 246 | } |
| 238 | 247 | |
| 248 | +func (h *Handlers) billingPrivateCollaborationBreakdown(r *http.Request, orgID int64) billingPrivateCollaborationBreakdown { | |
| 249 | + usage, err := entitlements.PrivateCollaborationUsageForOrg(r.Context(), entitlements.Deps{Pool: h.d.Pool}, orgID) | |
| 250 | + if err != nil { | |
| 251 | + h.d.Logger.WarnContext(r.Context(), "org billing: private collaboration usage", "org_id", orgID, "error", err) | |
| 252 | + return billingPrivateCollaborationBreakdown{ | |
| 253 | + LimitLabel: "Unavailable", | |
| 254 | + Detail: "Private collaborator usage could not be calculated right now.", | |
| 255 | + } | |
| 256 | + } | |
| 257 | + if usage.Unlimited { | |
| 258 | + return billingPrivateCollaborationBreakdown{ | |
| 259 | + Count: usage.Count, | |
| 260 | + LimitLabel: "Unlimited", | |
| 261 | + Detail: "Team billing allows unlimited effective private collaborators while the subscription is active or in grace.", | |
| 262 | + } | |
| 263 | + } | |
| 264 | + return billingPrivateCollaborationBreakdown{ | |
| 265 | + Count: usage.Count, | |
| 266 | + LimitLabel: fmt.Sprintf("%d", usage.Limit), | |
| 267 | + Detail: "Free organizations can add up to 3 unique people with effective access to private org repositories. Public collaboration is not counted.", | |
| 268 | + } | |
| 269 | +} | |
| 270 | + | |
| 239 | 271 | func (h *Handlers) ensureStripeCustomer(r *http.Request, org orgsdb.Org, state orgbilling.State) (orgbilling.State, error) { |
| 240 | 272 | if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" { |
| 241 | 273 | return state, nil |
internal/web/templates/orgs/settings_billing.htmlmodified@@ -73,6 +73,11 @@ | ||
| 73 | 73 | <td>{{ .Seats.PendingInvites }}</td> |
| 74 | 74 | <td>Open invitations are shown separately and are not billed until accepted.</td> |
| 75 | 75 | </tr> |
| 76 | + <tr> | |
| 77 | + <td>Private collaborators</td> | |
| 78 | + <td>{{ .PrivateCollaboration.Count }} / {{ .PrivateCollaboration.LimitLabel }}</td> | |
| 79 | + <td>{{ .PrivateCollaboration.Detail }}</td> | |
| 80 | + </tr> | |
| 76 | 81 | </tbody> |
| 77 | 82 | </table> |
| 78 | 83 | </div> |
@@ -160,6 +165,12 @@ | ||
| 160 | 165 | <td>Included</td> |
| 161 | 166 | <td>Contact sales</td> |
| 162 | 167 | </tr> |
| 168 | + <tr> | |
| 169 | + <td>Private org collaborators</td> | |
| 170 | + <td>Up to 3 effective collaborators</td> | |
| 171 | + <td>Unlimited while active</td> | |
| 172 | + <td>Contact sales</td> | |
| 173 | + </tr> | |
| 163 | 174 | <tr> |
| 164 | 175 | <td>Secret teams</td> |
| 165 | 176 | <td>Upgrade</td> |