tenseleyflow/shithub / b7a3679

Browse files

Complete billing settings page

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b7a367929b6fdabe1e03d3602a9350b950d1dbd3
Parents
4f220ec
Tree
d96b6c7

4 changed files

StatusFile+-
M docs/internal/billing.md 20 0
M internal/web/handlers/orgs/billing_settings.go 137 1
M internal/web/handlers/orgs/billing_test.go 137 2
M internal/web/templates/orgs/settings_billing.html 83 3
docs/internal/billing.mdmodified
@@ -204,6 +204,26 @@ PAYMENTS SP06 wires the first Team gates:
204204
 - Org-level Actions secrets and variables API routes require
205205
   organization owner or site-admin access before entitlement checks.
206206
 
207
+PAYMENTS SP07 completes the first self-serve billing settings surface:
208
+
209
+- `GET /organizations/{org}/settings/billing` is owner-only and is
210
+  linked from organization settings when Stripe Billing is configured.
211
+- The page shows current local plan state, subscription status, payment
212
+  source summary, recent Stripe-synced invoice snapshots, and actionable
213
+  banners for past-due, grace-period, canceled, scheduled-cancel, and
214
+  billing-action-needed states.
215
+- Seat accounting is shown as three separate values: current active
216
+  members, billable seats from the latest local billing state, and
217
+  pending invitations. Pending invitations are explicitly not billed
218
+  until accepted.
219
+- Team organizations manage payment method, invoices, cancellation, and
220
+  downgrade through Stripe Billing Portal. shithub never collects card
221
+  data directly and downgrades continue to preserve paid configuration
222
+  as read-only data.
223
+- Normal organization owners do not see raw Stripe customer,
224
+  subscription, or subscription-item IDs. Site admins see a debug panel
225
+  with those IDs and the latest locally recorded webhook receipt state.
226
+
207227
 ## Entitlement architecture
208228
 
209229
 Paid feature checks must live behind a central entitlement package, not
internal/web/handlers/orgs/billing_settings.gomodified
@@ -3,12 +3,16 @@
33
 package orgs
44
 
55
 import (
6
+	"errors"
67
 	"fmt"
78
 	"net/http"
89
 	"net/url"
910
 	"strings"
1011
 	"time"
1112
 
13
+	"github.com/jackc/pgx/v5"
14
+	"github.com/jackc/pgx/v5/pgtype"
15
+
1216
 	orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
1317
 	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
1418
 	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
@@ -33,6 +37,33 @@ type billingInvoiceView struct {
3337
 	InvoicePDFURL    string
3438
 }
3539
 
40
+type billingSeatBreakdown struct {
41
+	ActiveMembers  int
42
+	BillableSeats  int64
43
+	PendingInvites int
44
+	SnapshotLabel  string
45
+}
46
+
47
+type billingAlert struct {
48
+	Class      string
49
+	Message    string
50
+	ActionText string
51
+	ActionHref string
52
+}
53
+
54
+type billingDebugView struct {
55
+	StripeCustomerID         string
56
+	StripeSubscriptionID     string
57
+	StripeSubscriptionItemID string
58
+	LastWebhookEventID       string
59
+	LastWebhookEventType     string
60
+	LastWebhookStatus        string
61
+	LastWebhookReceivedAt    string
62
+	LastWebhookProcessedAt   string
63
+	LastWebhookAttempts      int32
64
+	LastWebhookError         string
65
+}
66
+
3667
 func (h *Handlers) settingsBilling(w http.ResponseWriter, r *http.Request) {
3768
 	org, ok := h.loadOrgSettingsOwner(w, r)
3869
 	if !ok {
@@ -169,11 +200,20 @@ func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request,
169200
 		h.d.Logger.WarnContext(r.Context(), "org billing: count members", "org_id", org.ID, "error", err)
170201
 		memberCount = int(state.BillableSeats)
171202
 	}
203
+	pendingInviteCount, err := orgbilling.CountPendingOrgInvitations(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
204
+	if err != nil {
205
+		h.d.Logger.WarnContext(r.Context(), "org billing: count pending invitations", "org_id", org.ID, "error", err)
206
+	}
172207
 	invoices, err := orgbilling.ListInvoicesForOrg(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, 10)
173208
 	if err != nil {
174209
 		h.d.Logger.WarnContext(r.Context(), "org billing: list invoices", "org_id", org.ID, "error", err)
175210
 		invoices = nil
176211
 	}
212
+	viewer := middleware.CurrentUserFromContext(r.Context())
213
+	debug := billingDebugView{}
214
+	if viewer.IsSiteAdmin {
215
+		debug = h.billingDebugView(r, state)
216
+	}
177217
 	_ = h.d.Render.RenderPage(w, r, "orgs/settings_billing", map[string]any{
178218
 		"Title":                 org.Slug + " - billing and plans",
179219
 		"CSRFToken":             middleware.CSRFTokenForRequest(r),
@@ -184,11 +224,15 @@ func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request,
184224
 		"BillingEnabled":        h.d.BillingEnabled,
185225
 		"Error":                 errMsg,
186226
 		"Notice":                notice,
227
+		"BillingAlert":          billingAlertForState(state, org.Slug),
187228
 		"Summary":               billingSummary(state, memberCount),
229
+		"Seats":                 billingSeatBreakdown{ActiveMembers: memberCount, BillableSeats: int64(state.BillableSeats), PendingInvites: pendingInviteCount, SnapshotLabel: billingSeatDetail(state)},
188230
 		"CanStartCheckout":      h.billingConfigured(),
189231
 		"CanManageSubscription": h.billingConfigured() && state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "",
190232
 		"GracePeriodLabel":      formatGracePeriod(h.d.BillingGracePeriod),
191233
 		"Invoices":              billingInvoiceViews(invoices),
234
+		"IsSiteAdmin":           viewer.IsSiteAdmin,
235
+		"Debug":                 debug,
192236
 	})
193237
 }
194238
 
@@ -245,6 +289,39 @@ func billingNotice(code string) string {
245289
 	}
246290
 }
247291
 
292
+func (h *Handlers) billingDebugView(r *http.Request, state orgbilling.State) billingDebugView {
293
+	debug := billingDebugView{
294
+		StripeCustomerID:         pgTextString(state.StripeCustomerID),
295
+		StripeSubscriptionID:     pgTextString(state.StripeSubscriptionID),
296
+		StripeSubscriptionItemID: pgTextString(state.StripeSubscriptionItemID),
297
+		LastWebhookEventID:       strings.TrimSpace(state.LastWebhookEventID),
298
+	}
299
+	if debug.LastWebhookEventID == "" {
300
+		return debug
301
+	}
302
+	receipt, err := orgbilling.GetWebhookEventReceipt(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, debug.LastWebhookEventID)
303
+	if err != nil {
304
+		if !errors.Is(err, pgx.ErrNoRows) {
305
+			h.d.Logger.WarnContext(r.Context(), "org billing: load latest webhook receipt",
306
+				"event_id", debug.LastWebhookEventID, "error", err)
307
+		}
308
+		return debug
309
+	}
310
+	debug.LastWebhookEventType = receipt.EventType
311
+	debug.LastWebhookReceivedAt = formatOptionalTime(receipt.ReceivedAt)
312
+	debug.LastWebhookProcessedAt = formatOptionalTime(receipt.ProcessedAt)
313
+	debug.LastWebhookAttempts = receipt.ProcessingAttempts
314
+	debug.LastWebhookError = strings.TrimSpace(receipt.ProcessError)
315
+	if receipt.ProcessedAt.Valid {
316
+		debug.LastWebhookStatus = "processed"
317
+	} else if debug.LastWebhookError != "" {
318
+		debug.LastWebhookStatus = "failed"
319
+	} else {
320
+		debug.LastWebhookStatus = "pending"
321
+	}
322
+	return debug
323
+}
324
+
248325
 func billingSummary(state orgbilling.State, memberCount int) []billingSummaryItem {
249326
 	summary := []billingSummaryItem{
250327
 		{
@@ -350,11 +427,56 @@ func billingPaymentSourceLabel(state orgbilling.State) string {
350427
 
351428
 func billingPaymentSourceDetail(state orgbilling.State) string {
352429
 	if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
353
-		return state.StripeCustomerID.String
430
+		return "Payment method and invoices are managed in Stripe Billing Portal."
354431
 	}
355432
 	return "Checkout creates a customer record the first time this organization upgrades."
356433
 }
357434
 
435
+func billingAlertForState(state orgbilling.State, orgSlug string) billingAlert {
436
+	path := orgBillingSettingsPath(orgSlug)
437
+	switch state.SubscriptionStatus {
438
+	case orgbilling.SubscriptionStatusPastDue:
439
+		if state.GraceUntil.Valid && time.Now().UTC().Before(state.GraceUntil.Time) {
440
+			return billingAlert{
441
+				Class:      "shithub-flash-notice",
442
+				Message:    "Payment failed. Team features remain available during the billing grace period, which ends " + state.GraceUntil.Time.Format("Jan 2, 2006") + ".",
443
+				ActionText: "Manage billing",
444
+				ActionHref: path,
445
+			}
446
+		}
447
+		return billingAlert{
448
+			Class:      "shithub-flash-error",
449
+			Message:    "Payment is past due. Team-only features are read-only until billing is brought back into good standing.",
450
+			ActionText: "Manage billing",
451
+			ActionHref: path,
452
+		}
453
+	case orgbilling.SubscriptionStatusCanceled:
454
+		return billingAlert{
455
+			Class:      "shithub-flash-notice",
456
+			Message:    "This organization is on Free after cancellation. Existing paid configuration is preserved, but Team-only features are read-only until reactivated.",
457
+			ActionText: "Upgrade to Team",
458
+			ActionHref: path + "#manage-plan",
459
+		}
460
+	case orgbilling.SubscriptionStatusIncomplete, orgbilling.SubscriptionStatusUnpaid, orgbilling.SubscriptionStatusPaused:
461
+		return billingAlert{
462
+			Class:      "shithub-flash-error",
463
+			Message:    "This subscription needs billing action before Team features are available.",
464
+			ActionText: "Manage billing",
465
+			ActionHref: path,
466
+		}
467
+	default:
468
+		if state.CancelAtPeriodEnd && state.CurrentPeriodEnd.Valid {
469
+			return billingAlert{
470
+				Class:      "shithub-flash-notice",
471
+				Message:    "Team is scheduled to cancel at the end of the current billing period on " + state.CurrentPeriodEnd.Time.Format("Jan 2, 2006") + ".",
472
+				ActionText: "Manage billing",
473
+				ActionHref: path,
474
+			}
475
+		}
476
+		return billingAlert{}
477
+	}
478
+}
479
+
358480
 func billingInvoiceViews(invoices []billingdb.BillingInvoice) []billingInvoiceView {
359481
 	items := make([]billingInvoiceView, 0, len(invoices))
360482
 	for _, inv := range invoices {
@@ -425,6 +547,20 @@ func formatGracePeriod(d time.Duration) string {
425547
 	return d.String()
426548
 }
427549
 
550
+func pgTextString(v pgtype.Text) string {
551
+	if !v.Valid {
552
+		return ""
553
+	}
554
+	return strings.TrimSpace(v.String)
555
+}
556
+
557
+func formatOptionalTime(v pgtype.Timestamptz) string {
558
+	if v.Valid && !v.Time.IsZero() {
559
+		return v.Time.UTC().Format("Jan 2, 2006 15:04 UTC")
560
+	}
561
+	return ""
562
+}
563
+
428564
 func formatCurrencyAmount(currency string, cents int64) string {
429565
 	currency = strings.ToUpper(strings.TrimSpace(currency))
430566
 	sign := ""
internal/web/handlers/orgs/billing_test.gomodified
@@ -137,6 +137,127 @@ func TestOrgBillingResultPagesRenderPostCheckoutState(t *testing.T) {
137137
 	}
138138
 }
139139
 
140
+func TestOrgBillingSettingsRequiresOwner(t *testing.T) {
141
+	t.Parallel()
142
+	pool := dbtest.NewTestDB(t)
143
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
144
+	insertOrgAvatarOrg(t, pool, ownerID, "acme")
145
+	memberID := insertOrgAvatarUser(t, pool, "member")
146
+	mux := newOrgBillingMuxForUser(t, pool, middleware.CurrentUser{ID: memberID, Username: "member"}, &fakeStripeRemote{})
147
+
148
+	resp := httptest.NewRecorder()
149
+	req := newOrgFormRequest(http.MethodGet, "/organizations/acme/settings/billing", nil)
150
+	mux.ServeHTTP(resp, req)
151
+	if resp.Code != http.StatusForbidden {
152
+		t.Fatalf("settings status=%d body=%s", resp.Code, resp.Body.String())
153
+	}
154
+}
155
+
156
+func TestOrgBillingSettingsRendersSeatBreakdownAndHidesStripeIDs(t *testing.T) {
157
+	t.Parallel()
158
+	ctx := context.Background()
159
+	pool := dbtest.NewTestDB(t)
160
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
161
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
162
+	if _, err := orgbilling.SetStripeCustomer(ctx, orgbilling.Deps{Pool: pool}, orgID, "cus_owner_secret"); err != nil {
163
+		t.Fatalf("SetStripeCustomer: %v", err)
164
+	}
165
+	if _, err := orgbilling.SyncSeatSnapshot(ctx, orgbilling.Deps{Pool: pool}, orgbilling.SeatSnapshot{
166
+		OrgID:         orgID,
167
+		ActiveMembers: 3,
168
+		BillableSeats: 3,
169
+		Source:        "test",
170
+	}); err != nil {
171
+		t.Fatalf("SyncSeatSnapshot: %v", err)
172
+	}
173
+	insertBillingPendingInvitation(t, pool, orgID, "pending@example.com", []byte{1, 2, 3})
174
+	mux := newOrgBillingMux(t, pool, ownerID, &fakeStripeRemote{})
175
+
176
+	resp := httptest.NewRecorder()
177
+	req := newOrgFormRequest(http.MethodGet, "/organizations/acme/settings/billing", nil)
178
+	mux.ServeHTTP(resp, req)
179
+	body := resp.Body.String()
180
+	if resp.Code != http.StatusOK {
181
+		t.Fatalf("settings status=%d body=%s", resp.Code, body)
182
+	}
183
+	if !strings.Contains(body, "SEATS=1/3/1;") {
184
+		t.Fatalf("settings did not render seat breakdown: %s", body)
185
+	}
186
+	if strings.Contains(body, "cus_owner_secret") {
187
+		t.Fatalf("owner billing page leaked Stripe customer id: %s", body)
188
+	}
189
+}
190
+
191
+func TestOrgBillingSettingsSiteAdminDebugShowsProviderState(t *testing.T) {
192
+	t.Parallel()
193
+	ctx := context.Background()
194
+	pool := dbtest.NewTestDB(t)
195
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
196
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
197
+	deps := orgbilling.Deps{Pool: pool}
198
+	if _, err := orgbilling.SetStripeCustomer(ctx, deps, orgID, "cus_debug"); err != nil {
199
+		t.Fatalf("SetStripeCustomer: %v", err)
200
+	}
201
+	if _, err := orgbilling.ApplySubscriptionSnapshot(ctx, deps, orgbilling.SubscriptionSnapshot{
202
+		OrgID:                    orgID,
203
+		Plan:                     orgbilling.PlanTeam,
204
+		Status:                   orgbilling.SubscriptionStatusActive,
205
+		StripeSubscriptionID:     "sub_debug",
206
+		StripeSubscriptionItemID: "si_debug",
207
+		CurrentPeriodStart:       time.Now().UTC().Add(-time.Hour),
208
+		CurrentPeriodEnd:         time.Now().UTC().Add(30 * 24 * time.Hour),
209
+		LastWebhookEventID:       "evt_debug",
210
+	}); err != nil {
211
+		t.Fatalf("ApplySubscriptionSnapshot: %v", err)
212
+	}
213
+	if _, _, err := orgbilling.RecordWebhookEvent(ctx, deps, orgbilling.WebhookEvent{
214
+		ProviderEventID: "evt_debug",
215
+		EventType:       "customer.subscription.updated",
216
+		APIVersion:      "2024-06-20",
217
+		Payload:         []byte(`{"id":"evt_debug"}`),
218
+	}); err != nil {
219
+		t.Fatalf("RecordWebhookEvent: %v", err)
220
+	}
221
+	if _, err := orgbilling.MarkWebhookEventProcessed(ctx, deps, "evt_debug"); err != nil {
222
+		t.Fatalf("MarkWebhookEventProcessed: %v", err)
223
+	}
224
+	mux := newOrgBillingMuxForUser(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner", IsSiteAdmin: true}, &fakeStripeRemote{})
225
+
226
+	resp := httptest.NewRecorder()
227
+	req := newOrgFormRequest(http.MethodGet, "/organizations/acme/settings/billing", nil)
228
+	mux.ServeHTTP(resp, req)
229
+	body := resp.Body.String()
230
+	if resp.Code != http.StatusOK {
231
+		t.Fatalf("settings status=%d body=%s", resp.Code, body)
232
+	}
233
+	if !strings.Contains(body, "DEBUG=cus_debug|sub_debug|si_debug|evt_debug|processed;") {
234
+		t.Fatalf("settings did not render site-admin debug state: %s", body)
235
+	}
236
+}
237
+
238
+func TestOrgBillingSettingsShowsPastDueAlert(t *testing.T) {
239
+	t.Parallel()
240
+	ctx := context.Background()
241
+	pool := dbtest.NewTestDB(t)
242
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
243
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
244
+	if _, err := orgbilling.MarkPastDue(ctx, orgbilling.Deps{Pool: pool}, orgID, time.Now().UTC().Add(24*time.Hour), "evt_failed"); err != nil {
245
+		t.Fatalf("MarkPastDue: %v", err)
246
+	}
247
+	mux := newOrgBillingMux(t, pool, ownerID, &fakeStripeRemote{})
248
+
249
+	resp := httptest.NewRecorder()
250
+	req := newOrgFormRequest(http.MethodGet, "/organizations/acme/settings/billing", nil)
251
+	mux.ServeHTTP(resp, req)
252
+	body := resp.Body.String()
253
+	if resp.Code != http.StatusOK {
254
+		t.Fatalf("settings status=%d body=%s", resp.Code, body)
255
+	}
256
+	if !strings.Contains(body, "ALERT=Payment failed.") {
257
+		t.Fatalf("settings did not render past-due alert: %s", body)
258
+	}
259
+}
260
+
140261
 func TestOrgBillingWebhookProcessesSubscriptionAndStaysIdempotent(t *testing.T) {
141262
 	t.Parallel()
142263
 	ctx := context.Background()
@@ -460,11 +581,16 @@ func postBillingWebhook(t *testing.T, mux *chi.Mux, eventID string) *httptest.Re
460581
 }
461582
 
462583
 func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote stripebilling.Remote) *chi.Mux {
584
+	t.Helper()
585
+	return newOrgBillingMuxForUser(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, remote)
586
+}
587
+
588
+func newOrgBillingMuxForUser(t *testing.T, pool *pgxpool.Pool, viewer middleware.CurrentUser, remote stripebilling.Remote) *chi.Mux {
463589
 	t.Helper()
464590
 	tmplFS := fstest.MapFS{
465591
 		"_layout.html":               {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
466592
 		"orgs/billing_result.html":   {Data: []byte(`{{ define "page" }}RESULT={{ .Result }};HEADING={{ .Heading }};MESSAGE={{ .Message }};BILLING={{ .BillingPath }}{{ end }}`)},
467
-		"orgs/settings_billing.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Notice }}NOTICE={{ . }}{{ end }}{{ range .Invoices }}INVOICE={{ .Number }};{{ end }}{{ end }}`)},
593
+		"orgs/settings_billing.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Notice }}NOTICE={{ . }}{{ end }}{{ with .BillingAlert }}{{ if .Message }}ALERT={{ .Message }}{{ end }}{{ end }}SEATS={{ .Seats.ActiveMembers }}/{{ .Seats.BillableSeats }}/{{ .Seats.PendingInvites }};{{ range .Summary }}{{ if eq .Label "Payment source" }}PAYMENT={{ .Detail }};{{ end }}{{ end }}{{ if .IsSiteAdmin }}DEBUG={{ .Debug.StripeCustomerID }}|{{ .Debug.StripeSubscriptionID }}|{{ .Debug.StripeSubscriptionItemID }}|{{ .Debug.LastWebhookEventID }}|{{ .Debug.LastWebhookStatus }};{{ end }}{{ range .Invoices }}INVOICE={{ .Number }};{{ end }}{{ end }}`)},
468594
 		"errors/403.html":            {Data: []byte(`{{ define "page" }}403{{ end }}`)},
469595
 		"errors/404.html":            {Data: []byte(`{{ define "page" }}404{{ end }}`)},
470596
 		"errors/500.html":            {Data: []byte(`{{ define "page" }}500{{ end }}`)},
@@ -491,7 +617,6 @@ func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote st
491617
 	mux := chi.NewRouter()
492618
 	mux.Use(func(next http.Handler) http.Handler {
493619
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
494
-			viewer := middleware.CurrentUser{ID: ownerID, Username: "owner"}
495620
 			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
496621
 		})
497622
 	})
@@ -499,3 +624,13 @@ func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote st
499624
 	h.MountBillingWebhook(mux)
500625
 	return mux
501626
 }
627
+
628
+func insertBillingPendingInvitation(t *testing.T, db *pgxpool.Pool, orgID int64, email string, token []byte) {
629
+	t.Helper()
630
+	if _, err := db.Exec(context.Background(), `
631
+		INSERT INTO org_invitations (org_id, target_email, role, token_hash, expires_at)
632
+		VALUES ($1, $2, 'member', $3, now() + interval '1 day')
633
+	`, orgID, email, token); err != nil {
634
+		t.Fatalf("insert pending billing invitation: %v", err)
635
+	}
636
+}
internal/web/templates/orgs/settings_billing.htmlmodified
@@ -15,6 +15,12 @@
1515
     <div class="shithub-org-settings-main">
1616
       {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
1717
       {{ with .Notice }}<p class="shithub-flash shithub-flash-success" role="status">{{ . }}</p>{{ end }}
18
+      {{ with .BillingAlert }}{{ if .Message }}
19
+      <p class="shithub-flash {{ .Class }}" role="status">
20
+        {{ .Message }}
21
+        {{ if .ActionHref }}<a href="{{ .ActionHref }}">{{ .ActionText }}</a>{{ end }}
22
+      </p>
23
+      {{ end }}{{ end }}
1824
 
1925
       <div class="Subhead">
2026
         <h2 class="Subhead-heading Subhead-heading--large">Billing and plans</h2>
@@ -37,7 +43,43 @@
3743
         </div>
3844
       </section>
3945
 
40
-      <section class="shithub-org-settings-section" aria-labelledby="org-billing-manage-heading">
46
+      <section class="shithub-org-settings-section" aria-labelledby="org-billing-seats-heading">
47
+        <div class="Subhead Subhead--spacious border-bottom-0 mb-0 tmp-mb-0">
48
+          <h2 id="org-billing-seats-heading" class="Subhead-heading">Seats</h2>
49
+        </div>
50
+        <div class="Box">
51
+          <div class="Box-body p-0">
52
+            <table class="shithub-org-billing-table">
53
+              <thead>
54
+                <tr>
55
+                  <th scope="col">Type</th>
56
+                  <th scope="col">Count</th>
57
+                  <th scope="col">How it is used</th>
58
+                </tr>
59
+              </thead>
60
+              <tbody>
61
+                <tr>
62
+                  <td>Active members</td>
63
+                  <td>{{ .Seats.ActiveMembers }}</td>
64
+                  <td>Current organization members. Team billing charges active members, including owners.</td>
65
+                </tr>
66
+                <tr>
67
+                  <td>Billable seats</td>
68
+                  <td>{{ .Seats.BillableSeats }}</td>
69
+                  <td>{{ .Seats.SnapshotLabel }}</td>
70
+                </tr>
71
+                <tr>
72
+                  <td>Pending invitations</td>
73
+                  <td>{{ .Seats.PendingInvites }}</td>
74
+                  <td>Open invitations are shown separately and are not billed until accepted.</td>
75
+                </tr>
76
+              </tbody>
77
+            </table>
78
+          </div>
79
+        </div>
80
+      </section>
81
+
82
+      <section id="manage-plan" class="shithub-org-settings-section" aria-labelledby="org-billing-manage-heading">
4183
         <div class="Subhead Subhead--spacious border-bottom-0 mb-0 tmp-mb-0">
4284
           <h2 id="org-billing-manage-heading" class="Subhead-heading">Manage plan</h2>
4385
         </div>
@@ -45,12 +87,12 @@
4587
           <div class="shithub-org-settings-row">
4688
             <div>
4789
               <strong>Team plan</strong>
48
-              <p>$4 per active organization member per month. Public and private repositories stay available; billing applies to hosted organization collaboration.</p>
90
+              <p>$4 per active organization member per month. Public and private repositories stay available; Team unlocks paid organization controls.</p>
4991
             </div>
5092
             {{ if .CanManageSubscription }}
5193
             <form method="POST" action="/organizations/{{ .Org.Slug }}/billing/portal">
5294
               <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
53
-              <button type="submit" class="shithub-button">Manage billing</button>
95
+              <button type="submit" class="shithub-button">Manage or cancel</button>
5496
             </form>
5597
             {{ else if .CanStartCheckout }}
5698
             <form method="POST" action="/organizations/{{ .Org.Slug }}/billing/checkout">
@@ -61,6 +103,18 @@
61103
             <span class="shithub-empty-note">Billing is not configured on this instance.</span>
62104
             {{ end }}
63105
           </div>
106
+          {{ if .CanManageSubscription }}
107
+          <div class="shithub-org-settings-row">
108
+            <div>
109
+              <strong>Downgrade to Free</strong>
110
+              <p>Use Stripe Billing Portal to cancel or schedule cancellation. shithub preserves paid configuration and makes Team-only features read-only after downgrade.</p>
111
+            </div>
112
+            <form method="POST" action="/organizations/{{ .Org.Slug }}/billing/portal">
113
+              <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
114
+              <button type="submit" class="shithub-button shithub-button-danger">Open billing portal</button>
115
+            </form>
116
+          </div>
117
+          {{ end }}
64118
           <div class="shithub-org-settings-row">
65119
             <div>
66120
               <strong>Billing email</strong>
@@ -176,6 +230,32 @@
176230
         <p class="shithub-empty-note">No invoices have been recorded for this organization yet.</p>
177231
         {{ end }}
178232
       </section>
233
+
234
+      {{ if .IsSiteAdmin }}
235
+      <section class="shithub-org-settings-section" aria-labelledby="org-billing-debug-heading">
236
+        <div class="Subhead Subhead--spacious border-bottom-0 mb-0 tmp-mb-0">
237
+          <h2 id="org-billing-debug-heading" class="Subhead-heading">Site admin billing debug</h2>
238
+        </div>
239
+        <div class="Box">
240
+          <div class="Box-body p-0">
241
+            <table class="shithub-org-billing-table">
242
+              <tbody>
243
+                <tr><th scope="row">Stripe customer</th><td>{{ if .Debug.StripeCustomerID }}{{ .Debug.StripeCustomerID }}{{ else }}—{{ end }}</td></tr>
244
+                <tr><th scope="row">Stripe subscription</th><td>{{ if .Debug.StripeSubscriptionID }}{{ .Debug.StripeSubscriptionID }}{{ else }}—{{ end }}</td></tr>
245
+                <tr><th scope="row">Stripe subscription item</th><td>{{ if .Debug.StripeSubscriptionItemID }}{{ .Debug.StripeSubscriptionItemID }}{{ else }}—{{ end }}</td></tr>
246
+                <tr><th scope="row">Last webhook event</th><td>{{ if .Debug.LastWebhookEventID }}{{ .Debug.LastWebhookEventID }}{{ else }}—{{ end }}</td></tr>
247
+                <tr><th scope="row">Webhook type</th><td>{{ if .Debug.LastWebhookEventType }}{{ .Debug.LastWebhookEventType }}{{ else }}—{{ end }}</td></tr>
248
+                <tr><th scope="row">Webhook status</th><td>{{ if .Debug.LastWebhookStatus }}{{ .Debug.LastWebhookStatus }}{{ else }}—{{ end }}</td></tr>
249
+                <tr><th scope="row">Webhook received</th><td>{{ if .Debug.LastWebhookReceivedAt }}{{ .Debug.LastWebhookReceivedAt }}{{ else }}—{{ end }}</td></tr>
250
+                <tr><th scope="row">Webhook processed</th><td>{{ if .Debug.LastWebhookProcessedAt }}{{ .Debug.LastWebhookProcessedAt }}{{ else }}—{{ end }}</td></tr>
251
+                <tr><th scope="row">Webhook attempts</th><td>{{ .Debug.LastWebhookAttempts }}</td></tr>
252
+                <tr><th scope="row">Webhook error</th><td>{{ if .Debug.LastWebhookError }}{{ .Debug.LastWebhookError }}{{ else }}—{{ end }}</td></tr>
253
+              </tbody>
254
+            </table>
255
+          </div>
256
+        </div>
257
+      </section>
258
+      {{ end }}
179259
     </div>
180260
   </div>
181261
 </section>