tenseleyflow/shithub / c6d6c16

Browse files

Document private collaboration limits

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c6d6c1673123bec635f609a13435449cafce0112
Parents
09c93d2
Tree
108d760

4 changed files

StatusFile+-
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:
6969
 | Public org repositories | Included | Included | Contact sales |
7070
 | Basic private org repositories | Included | Included | Contact sales |
7171
 | 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 |
7273
 | Visible teams | Included | Included | Contact sales |
7374
 | Secret teams | Upgrade | Included | Contact sales |
7475
 | Basic branch protection | Included | Included | Contact sales |
@@ -224,6 +225,28 @@ PAYMENTS SP07 completes the first self-serve billing settings surface:
224225
   subscription, or subscription-item IDs. Site admins see a debug panel
225226
   with those IDs and the latest locally recorded webhook receipt state.
226227
 
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
+
227250
 ## Entitlement architecture
228251
 
229252
 Paid feature checks must live behind a central entitlement package, not
@@ -283,9 +306,6 @@ organization upgrades again.
283306
 
284307
 ## Open questions for implementation
285308
 
286
-- Whether Free should limit private org collaborators before usage
287
-  metering exists, or whether the first paid gates are advanced controls
288
-  only.
289309
 - Exact Free and Team quota numbers for Actions and storage. These must
290310
   come from real host-cost estimates before SP08.
291311
 
internal/orgs/private_collaboration_test.gomodified
@@ -73,6 +73,48 @@ func TestTeamExpansionRespectsPrivateCollaborationLimit(t *testing.T) {
7373
 	}
7474
 }
7575
 
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
+
76118
 func mustOrgRepo(t *testing.T, db reposdb.DBTX, orgID int64, name, visibility string) reposdb.Repo {
77119
 	t.Helper()
78120
 	repo, err := reposdb.New().CreateRepo(context.Background(), db, reposdb.CreateRepoParams{
internal/web/handlers/orgs/billing_settings.gomodified
@@ -16,6 +16,7 @@ import (
1616
 	orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
1717
 	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
1818
 	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
19
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1920
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
2021
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
2122
 )
@@ -44,6 +45,12 @@ type billingSeatBreakdown struct {
4445
 	SnapshotLabel  string
4546
 }
4647
 
48
+type billingPrivateCollaborationBreakdown struct {
49
+	Count      int64
50
+	LimitLabel string
51
+	Detail     string
52
+}
53
+
4754
 type billingAlert struct {
4855
 	Class      string
4956
 	Message    string
@@ -204,6 +211,7 @@ func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request,
204211
 	if err != nil {
205212
 		h.d.Logger.WarnContext(r.Context(), "org billing: count pending invitations", "org_id", org.ID, "error", err)
206213
 	}
214
+	privateCollab := h.billingPrivateCollaborationBreakdown(r, org.ID)
207215
 	invoices, err := orgbilling.ListInvoicesForOrg(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, 10)
208216
 	if err != nil {
209217
 		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,
227235
 		"BillingAlert":          billingAlertForState(state, org.Slug),
228236
 		"Summary":               billingSummary(state, memberCount),
229237
 		"Seats":                 billingSeatBreakdown{ActiveMembers: memberCount, BillableSeats: int64(state.BillableSeats), PendingInvites: pendingInviteCount, SnapshotLabel: billingSeatDetail(state)},
238
+		"PrivateCollaboration":  privateCollab,
230239
 		"CanStartCheckout":      h.billingConfigured(),
231240
 		"CanManageSubscription": h.billingConfigured() && state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "",
232241
 		"GracePeriodLabel":      formatGracePeriod(h.d.BillingGracePeriod),
@@ -236,6 +245,29 @@ func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request,
236245
 	})
237246
 }
238247
 
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
+
239271
 func (h *Handlers) ensureStripeCustomer(r *http.Request, org orgsdb.Org, state orgbilling.State) (orgbilling.State, error) {
240272
 	if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
241273
 		return state, nil
internal/web/templates/orgs/settings_billing.htmlmodified
@@ -73,6 +73,11 @@
7373
                   <td>{{ .Seats.PendingInvites }}</td>
7474
                   <td>Open invitations are shown separately and are not billed until accepted.</td>
7575
                 </tr>
76
+                <tr>
77
+                  <td>Private collaborators</td>
78
+                  <td>{{ .PrivateCollaboration.Count }} / {{ .PrivateCollaboration.LimitLabel }}</td>
79
+                  <td>{{ .PrivateCollaboration.Detail }}</td>
80
+                </tr>
7681
               </tbody>
7782
             </table>
7883
           </div>
@@ -160,6 +165,12 @@
160165
                   <td>Included</td>
161166
                   <td>Contact sales</td>
162167
                 </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>
163174
                 <tr>
164175
                   <td>Secret teams</td>
165176
                   <td>Upgrade</td>