tenseleyflow/shithub / 3c68bbc

Browse files

Add Stripe billing web routes

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
3c68bbc30861a8b81f7a8ab82b92ca091ddfe9ed
Parents
c6777a6
Tree
420de56

14 changed files

StatusFile+-
M internal/web/handlers/handlers.go 7 0
A internal/web/handlers/orgs/billing_settings.go 408 0
A internal/web/handlers/orgs/billing_test.go 241 0
A internal/web/handlers/orgs/billing_webhook.go 361 0
M internal/web/handlers/orgs/imports.go 12 10
M internal/web/handlers/orgs/orgs.go 36 10
M internal/web/handlers/orgs/settings_profile.go 2 0
M internal/web/orgs_wiring.go 30 10
M internal/web/server.go 3 0
M internal/web/static/css/shithub.css 78 0
A internal/web/templates/_org_settings_nav.html 27 0
A internal/web/templates/orgs/settings_billing.html 124 0
M internal/web/templates/orgs/settings_import.html 1 20
M internal/web/templates/orgs/settings_profile.html 1 20
internal/web/handlers/handlers.gomodified
@@ -132,6 +132,10 @@ type Deps struct {
132132
 	// needed, so the route lives in the public group alongside
133133
 	// /healthz / /static.
134134
 	NotifPublicMounter func(chi.Router)
135
+	// BillingWebhookMounter registers the Stripe webhook receiver at
136
+	// /stripe/webhook. It lives in the public, CSRF-exempt group and is
137
+	// mounted only when billing is enabled and fully configured.
138
+	BillingWebhookMounter func(chi.Router)
135139
 	// OrgCreateMounter registers /organizations/new + POST
136140
 	// /organizations (S30). Wrapped in RequireUser at the wiring
137141
 	// layer.
@@ -246,6 +250,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
246250
 		if deps.NotifPublicMounter != nil {
247251
 			deps.NotifPublicMounter(r)
248252
 		}
253
+		if deps.BillingWebhookMounter != nil {
254
+			deps.BillingWebhookMounter(r)
255
+		}
249256
 	})
250257
 
251258
 	// Smart-HTTP git routes get their own group: NO CSRF (HTTP Basic
internal/web/handlers/orgs/billing_settings.goadded
@@ -0,0 +1,408 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"fmt"
7
+	"net/http"
8
+	"net/url"
9
+	"strings"
10
+	"time"
11
+
12
+	orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
13
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
14
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
15
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+type billingSummaryItem struct {
20
+	Label  string
21
+	Value  string
22
+	Detail string
23
+}
24
+
25
+type billingInvoiceView struct {
26
+	Number           string
27
+	StatusLabel      string
28
+	StatusClass      string
29
+	AmountLabel      string
30
+	PeriodLabel      string
31
+	DueLabel         string
32
+	HostedInvoiceURL string
33
+	InvoicePDFURL    string
34
+}
35
+
36
+func (h *Handlers) settingsBilling(w http.ResponseWriter, r *http.Request) {
37
+	org, ok := h.loadOrgSettingsOwner(w, r)
38
+	if !ok {
39
+		return
40
+	}
41
+	h.renderSettingsBilling(w, r, org, "", billingNotice(r.URL.Query().Get("notice")))
42
+}
43
+
44
+func (h *Handlers) billingCheckout(w http.ResponseWriter, r *http.Request) {
45
+	org, ok := h.loadOrgSettingsOwner(w, r)
46
+	if !ok {
47
+		return
48
+	}
49
+	if !h.billingConfigured() {
50
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
51
+		return
52
+	}
53
+	state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
54
+	if err != nil {
55
+		h.d.Logger.ErrorContext(r.Context(), "org billing: load state for checkout", "org_id", org.ID, "error", err)
56
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
57
+		return
58
+	}
59
+	state, err = h.ensureStripeCustomer(r, org, state)
60
+	if err != nil {
61
+		h.d.Logger.ErrorContext(r.Context(), "org billing: ensure stripe customer", "org_id", org.ID, "error", err)
62
+		h.renderSettingsBilling(w, r, org, "Could not start checkout right now.", "")
63
+		return
64
+	}
65
+	seats, err := orgbilling.CountBillableOrgMembers(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
66
+	if err != nil {
67
+		h.d.Logger.ErrorContext(r.Context(), "org billing: count seats", "org_id", org.ID, "error", err)
68
+		h.renderSettingsBilling(w, r, org, "Could not calculate billable seats right now.", "")
69
+		return
70
+	}
71
+	session, err := h.d.Stripe.CreateCheckoutSession(r.Context(), stripebilling.CheckoutInput{
72
+		OrgID:      org.ID,
73
+		OrgSlug:    org.Slug,
74
+		CustomerID: state.StripeCustomerID.String,
75
+		SeatCount:  int64(seats),
76
+		SuccessURL: h.billingReturnURL(org.Slug, h.d.StripeSuccessURL, "/organizations/"+org.Slug+"/billing/success"),
77
+		CancelURL:  h.billingReturnURL(org.Slug, h.d.StripeCancelURL, "/organizations/"+org.Slug+"/billing/cancel"),
78
+	})
79
+	if err != nil {
80
+		h.d.Logger.ErrorContext(r.Context(), "org billing: create checkout session", "org_id", org.ID, "error", err)
81
+		h.renderSettingsBilling(w, r, org, "Could not create the Stripe checkout session.", "")
82
+		return
83
+	}
84
+	http.Redirect(w, r, session.URL, http.StatusSeeOther)
85
+}
86
+
87
+func (h *Handlers) billingPortal(w http.ResponseWriter, r *http.Request) {
88
+	org, ok := h.loadOrgSettingsOwner(w, r)
89
+	if !ok {
90
+		return
91
+	}
92
+	if !h.billingConfigured() {
93
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
94
+		return
95
+	}
96
+	state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
97
+	if err != nil {
98
+		h.d.Logger.ErrorContext(r.Context(), "org billing: load state for portal", "org_id", org.ID, "error", err)
99
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
100
+		return
101
+	}
102
+	if !state.StripeCustomerID.Valid || strings.TrimSpace(state.StripeCustomerID.String) == "" {
103
+		h.renderSettingsBilling(w, r, org, "Billing portal is unavailable until this organization has a Stripe customer record.", "")
104
+		return
105
+	}
106
+	session, err := h.d.Stripe.CreatePortalSession(r.Context(), stripebilling.PortalInput{
107
+		CustomerID: state.StripeCustomerID.String,
108
+		ReturnURL:  h.billingReturnURL(org.Slug, h.d.StripePortalReturnURL, orgBillingSettingsPath(org.Slug)),
109
+	})
110
+	if err != nil {
111
+		h.d.Logger.ErrorContext(r.Context(), "org billing: create portal session", "org_id", org.ID, "error", err)
112
+		h.renderSettingsBilling(w, r, org, "Could not open the Stripe billing portal right now.", "")
113
+		return
114
+	}
115
+	http.Redirect(w, r, session.URL, http.StatusSeeOther)
116
+}
117
+
118
+func (h *Handlers) billingSuccess(w http.ResponseWriter, r *http.Request) {
119
+	org, ok := h.loadOrgSettingsOwner(w, r)
120
+	if !ok {
121
+		return
122
+	}
123
+	http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=checkout-success", http.StatusSeeOther)
124
+}
125
+
126
+func (h *Handlers) billingCancel(w http.ResponseWriter, r *http.Request) {
127
+	org, ok := h.loadOrgSettingsOwner(w, r)
128
+	if !ok {
129
+		return
130
+	}
131
+	http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=checkout-canceled", http.StatusSeeOther)
132
+}
133
+
134
+func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request, org orgsdb.Org, errMsg, notice string) {
135
+	state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
136
+	if err != nil {
137
+		h.d.Logger.ErrorContext(r.Context(), "org billing: load state", "org_id", org.ID, "error", err)
138
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
139
+		return
140
+	}
141
+	memberCount, err := orgbilling.CountBillableOrgMembers(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
142
+	if err != nil {
143
+		h.d.Logger.WarnContext(r.Context(), "org billing: count members", "org_id", org.ID, "error", err)
144
+		memberCount = int(state.BillableSeats)
145
+	}
146
+	invoices, err := orgbilling.ListInvoicesForOrg(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, 10)
147
+	if err != nil {
148
+		h.d.Logger.WarnContext(r.Context(), "org billing: list invoices", "org_id", org.ID, "error", err)
149
+		invoices = nil
150
+	}
151
+	_ = h.d.Render.RenderPage(w, r, "orgs/settings_billing", map[string]any{
152
+		"Title":                 org.Slug + " - billing and plans",
153
+		"CSRFToken":             middleware.CSRFTokenForRequest(r),
154
+		"Org":                   org,
155
+		"AvatarURL":             "/avatars/" + url.PathEscape(org.Slug),
156
+		"ActiveOrgNav":          "settings",
157
+		"OrgSettingsActive":     "billing",
158
+		"BillingEnabled":        h.d.BillingEnabled,
159
+		"Error":                 errMsg,
160
+		"Notice":                notice,
161
+		"Summary":               billingSummary(state, memberCount),
162
+		"CanStartCheckout":      h.billingConfigured(),
163
+		"CanManageSubscription": h.billingConfigured() && state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "",
164
+		"GracePeriodLabel":      formatGracePeriod(h.d.BillingGracePeriod),
165
+		"Invoices":              billingInvoiceViews(invoices),
166
+	})
167
+}
168
+
169
+func (h *Handlers) ensureStripeCustomer(r *http.Request, org orgsdb.Org, state orgbilling.State) (orgbilling.State, error) {
170
+	if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
171
+		return state, nil
172
+	}
173
+	customer, err := h.d.Stripe.CreateCustomer(r.Context(), stripebilling.CustomerInput{
174
+		OrgID:   org.ID,
175
+		OrgSlug: org.Slug,
176
+		OrgName: strings.TrimSpace(org.DisplayName),
177
+		Email:   strings.TrimSpace(org.BillingEmail),
178
+	})
179
+	if err != nil {
180
+		return orgbilling.State{}, err
181
+	}
182
+	return orgbilling.SetStripeCustomer(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, customer.ID)
183
+}
184
+
185
+func (h *Handlers) billingReturnURL(orgSlug, overrideURL, fallbackPath string) string {
186
+	overrideURL = strings.TrimSpace(overrideURL)
187
+	if overrideURL != "" {
188
+		return strings.ReplaceAll(overrideURL, "{org}", url.PathEscape(orgSlug))
189
+	}
190
+	base, err := url.Parse(strings.TrimRight(h.d.BaseURL, "/") + "/")
191
+	if err != nil {
192
+		return ""
193
+	}
194
+	rel, err := url.Parse(strings.TrimLeft(fallbackPath, "/"))
195
+	if err != nil {
196
+		return ""
197
+	}
198
+	return base.ResolveReference(rel).String()
199
+}
200
+
201
+func orgBillingSettingsPath(slug string) string {
202
+	return "/organizations/" + slug + "/settings/billing"
203
+}
204
+
205
+func billingNotice(code string) string {
206
+	switch code {
207
+	case "checkout-success":
208
+		return "Checkout completed. Stripe will finish provisioning as webhook events arrive."
209
+	case "checkout-canceled":
210
+		return "Checkout canceled."
211
+	default:
212
+		return ""
213
+	}
214
+}
215
+
216
+func billingSummary(state orgbilling.State, memberCount int) []billingSummaryItem {
217
+	summary := []billingSummaryItem{
218
+		{
219
+			Label:  "Current plan",
220
+			Value:  billingPlanLabel(state.Plan),
221
+			Detail: billingPlanDetail(state),
222
+		},
223
+		{
224
+			Label:  "Subscription",
225
+			Value:  billingStatusLabel(state.SubscriptionStatus),
226
+			Detail: billingStatusDetail(state),
227
+		},
228
+		{
229
+			Label:  "Billable members",
230
+			Value:  fmt.Sprintf("%d", memberCount),
231
+			Detail: billingSeatDetail(state),
232
+		},
233
+		{
234
+			Label:  "Payment source",
235
+			Value:  billingPaymentSourceLabel(state),
236
+			Detail: billingPaymentSourceDetail(state),
237
+		},
238
+	}
239
+	return summary
240
+}
241
+
242
+func billingPlanLabel(plan orgbilling.Plan) string {
243
+	switch plan {
244
+	case orgbilling.PlanTeam:
245
+		return "Team"
246
+	case orgbilling.PlanEnterprise:
247
+		return "Enterprise"
248
+	default:
249
+		return "Free"
250
+	}
251
+}
252
+
253
+func billingPlanDetail(state orgbilling.State) string {
254
+	if state.CurrentPeriodEnd.Valid {
255
+		label := "Current period ends"
256
+		if state.CancelAtPeriodEnd {
257
+			label = "Scheduled to cancel at period end"
258
+		}
259
+		return label + " " + state.CurrentPeriodEnd.Time.Format("Jan 2, 2006")
260
+	}
261
+	if state.Plan == orgbilling.PlanFree {
262
+		return "No active paid subscription."
263
+	}
264
+	return ""
265
+}
266
+
267
+func billingStatusLabel(status orgbilling.SubscriptionStatus) string {
268
+	switch status {
269
+	case orgbilling.SubscriptionStatusActive:
270
+		return "Active"
271
+	case orgbilling.SubscriptionStatusTrialing:
272
+		return "Trialing"
273
+	case orgbilling.SubscriptionStatusIncomplete:
274
+		return "Incomplete"
275
+	case orgbilling.SubscriptionStatusPastDue:
276
+		return "Past due"
277
+	case orgbilling.SubscriptionStatusCanceled:
278
+		return "Canceled"
279
+	case orgbilling.SubscriptionStatusUnpaid:
280
+		return "Unpaid"
281
+	case orgbilling.SubscriptionStatusPaused:
282
+		return "Paused"
283
+	default:
284
+		return "No subscription"
285
+	}
286
+}
287
+
288
+func billingStatusDetail(state orgbilling.State) string {
289
+	if state.GraceUntil.Valid {
290
+		return "Grace period until " + state.GraceUntil.Time.Format("Jan 2, 2006")
291
+	}
292
+	if state.CanceledAt.Valid {
293
+		return "Canceled " + state.CanceledAt.Time.Format("Jan 2, 2006")
294
+	}
295
+	if state.TrialEnd.Valid {
296
+		return "Trial ends " + state.TrialEnd.Time.Format("Jan 2, 2006")
297
+	}
298
+	return ""
299
+}
300
+
301
+func billingSeatDetail(state orgbilling.State) string {
302
+	if state.SeatSnapshotAt.Valid {
303
+		return fmt.Sprintf("Latest billed seat snapshot: %d captured %s", state.BillableSeats, state.SeatSnapshotAt.Time.Format("Jan 2, 2006"))
304
+	}
305
+	if state.BillableSeats > 0 {
306
+		return fmt.Sprintf("Latest billed seat snapshot: %d", state.BillableSeats)
307
+	}
308
+	return "Seat sync has not recorded a snapshot yet."
309
+}
310
+
311
+func billingPaymentSourceLabel(state orgbilling.State) string {
312
+	if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
313
+		return "Stripe customer connected"
314
+	}
315
+	return "Not connected"
316
+}
317
+
318
+func billingPaymentSourceDetail(state orgbilling.State) string {
319
+	if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
320
+		return state.StripeCustomerID.String
321
+	}
322
+	return "Checkout creates a customer record the first time this organization upgrades."
323
+}
324
+
325
+func billingInvoiceViews(invoices []billingdb.BillingInvoice) []billingInvoiceView {
326
+	items := make([]billingInvoiceView, 0, len(invoices))
327
+	for _, inv := range invoices {
328
+		number := strings.TrimSpace(inv.Number)
329
+		if number == "" {
330
+			number = inv.StripeInvoiceID
331
+		}
332
+		items = append(items, billingInvoiceView{
333
+			Number:           number,
334
+			StatusLabel:      billingInvoiceStatusLabel(inv.Status),
335
+			StatusClass:      strings.ReplaceAll(strings.ToLower(string(inv.Status)), "_", "-"),
336
+			AmountLabel:      formatCurrencyAmount(inv.Currency, inv.AmountDueCents),
337
+			PeriodLabel:      billingPeriodLabel(inv),
338
+			DueLabel:         billingDueLabel(inv),
339
+			HostedInvoiceURL: strings.TrimSpace(inv.HostedInvoiceUrl),
340
+			InvoicePDFURL:    strings.TrimSpace(inv.InvoicePdfUrl),
341
+		})
342
+	}
343
+	return items
344
+}
345
+
346
+func billingInvoiceStatusLabel(status orgbilling.InvoiceStatus) string {
347
+	switch status {
348
+	case orgbilling.InvoiceStatusOpen:
349
+		return "Open"
350
+	case orgbilling.InvoiceStatusPaid:
351
+		return "Paid"
352
+	case orgbilling.InvoiceStatusVoid:
353
+		return "Void"
354
+	case orgbilling.InvoiceStatusUncollectible:
355
+		return "Uncollectible"
356
+	default:
357
+		return "Draft"
358
+	}
359
+}
360
+
361
+func billingPeriodLabel(inv billingdb.BillingInvoice) string {
362
+	if inv.PeriodStart.Valid && inv.PeriodEnd.Valid {
363
+		return inv.PeriodStart.Time.Format("Jan 2, 2006") + " - " + inv.PeriodEnd.Time.Format("Jan 2, 2006")
364
+	}
365
+	return "—"
366
+}
367
+
368
+func billingDueLabel(inv billingdb.BillingInvoice) string {
369
+	switch {
370
+	case inv.PaidAt.Valid:
371
+		return "Paid " + inv.PaidAt.Time.Format("Jan 2, 2006")
372
+	case inv.VoidedAt.Valid:
373
+		return "Voided " + inv.VoidedAt.Time.Format("Jan 2, 2006")
374
+	case inv.DueAt.Valid:
375
+		return inv.DueAt.Time.Format("Jan 2, 2006")
376
+	default:
377
+		return "—"
378
+	}
379
+}
380
+
381
+func formatGracePeriod(d time.Duration) string {
382
+	if d <= 0 {
383
+		return "No grace period"
384
+	}
385
+	if d%(24*time.Hour) == 0 {
386
+		days := int(d / (24 * time.Hour))
387
+		if days == 1 {
388
+			return "1 day"
389
+		}
390
+		return fmt.Sprintf("%d days", days)
391
+	}
392
+	return d.String()
393
+}
394
+
395
+func formatCurrencyAmount(currency string, cents int64) string {
396
+	currency = strings.ToUpper(strings.TrimSpace(currency))
397
+	sign := ""
398
+	if cents < 0 {
399
+		sign = "-"
400
+		cents = -cents
401
+	}
402
+	major := cents / 100
403
+	minor := cents % 100
404
+	if currency == "" {
405
+		currency = "USD"
406
+	}
407
+	return fmt.Sprintf("%s$%d.%02d %s", sign, major, minor, currency)
408
+}
internal/web/handlers/orgs/billing_test.goadded
@@ -0,0 +1,241 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs_test
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"io"
9
+	"log/slog"
10
+	"net/http"
11
+	"net/http/httptest"
12
+	"net/url"
13
+	"strconv"
14
+	"strings"
15
+	"testing"
16
+	"testing/fstest"
17
+	"time"
18
+
19
+	"github.com/go-chi/chi/v5"
20
+	"github.com/jackc/pgx/v5/pgxpool"
21
+	stripeapi "github.com/stripe/stripe-go/v85"
22
+
23
+	orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
24
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
25
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
26
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
27
+	orgsh "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs"
28
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
29
+	"github.com/tenseleyFlow/shithub/internal/web/render"
30
+)
31
+
32
+func TestOrgBillingCheckoutRedirectsToStripeAndCreatesCustomer(t *testing.T) {
33
+	t.Parallel()
34
+	ctx := context.Background()
35
+	pool := dbtest.NewTestDB(t)
36
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
37
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
38
+	fake := &fakeStripeRemote{
39
+		createCustomerFn: func(_ context.Context, in stripebilling.CustomerInput) (stripebilling.Customer, error) {
40
+			if in.OrgID != orgID || in.OrgSlug != "acme" {
41
+				t.Fatalf("unexpected customer input: %+v", in)
42
+			}
43
+			return stripebilling.Customer{ID: "cus_test_checkout"}, nil
44
+		},
45
+		createCheckoutFn: func(_ context.Context, in stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error) {
46
+			if in.CustomerID != "cus_test_checkout" {
47
+				t.Fatalf("checkout customer = %q", in.CustomerID)
48
+			}
49
+			if in.SeatCount != 1 {
50
+				t.Fatalf("checkout seats = %d, want 1", in.SeatCount)
51
+			}
52
+			if !strings.Contains(in.SuccessURL, "/organizations/acme/billing/success") {
53
+				t.Fatalf("success url = %q", in.SuccessURL)
54
+			}
55
+			if !strings.Contains(in.CancelURL, "/organizations/acme/billing/cancel") {
56
+				t.Fatalf("cancel url = %q", in.CancelURL)
57
+			}
58
+			return stripebilling.CheckoutSession{ID: "cs_test", URL: "https://checkout.stripe.test/session"}, nil
59
+		},
60
+	}
61
+	mux := newOrgBillingMux(t, pool, ownerID, fake)
62
+
63
+	resp := httptest.NewRecorder()
64
+	req := newOrgFormRequest(http.MethodPost, "/organizations/acme/billing/checkout", url.Values{})
65
+	mux.ServeHTTP(resp, req)
66
+	if resp.Code != http.StatusSeeOther {
67
+		t.Fatalf("checkout status=%d body=%s", resp.Code, resp.Body.String())
68
+	}
69
+	if got := resp.Header().Get("Location"); got != "https://checkout.stripe.test/session" {
70
+		t.Fatalf("checkout redirect=%q", got)
71
+	}
72
+	state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID)
73
+	if err != nil {
74
+		t.Fatalf("GetOrgBillingState: %v", err)
75
+	}
76
+	if !state.StripeCustomerID.Valid || state.StripeCustomerID.String != "cus_test_checkout" {
77
+		t.Fatalf("expected stripe customer saved, got %+v", state.StripeCustomerID)
78
+	}
79
+}
80
+
81
+func TestOrgBillingWebhookProcessesSubscriptionAndStaysIdempotent(t *testing.T) {
82
+	t.Parallel()
83
+	ctx := context.Background()
84
+	pool := dbtest.NewTestDB(t)
85
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
86
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
87
+	raw, err := json.Marshal(map[string]any{
88
+		"id":                   "sub_test",
89
+		"customer":             "cus_test_webhook",
90
+		"status":               "active",
91
+		"cancel_at_period_end": false,
92
+		"trial_end":            int64(0),
93
+		"canceled_at":          int64(0),
94
+		"metadata":             map[string]string{stripebilling.MetadataOrgID: strconv.FormatInt(orgID, 10)},
95
+		"items": map[string]any{"data": []map[string]any{{
96
+			"id":                   "si_test_webhook",
97
+			"current_period_start": time.Now().UTC().Add(-time.Hour).Unix(),
98
+			"current_period_end":   time.Now().UTC().Add(30 * 24 * time.Hour).Unix(),
99
+		}}},
100
+	})
101
+	if err != nil {
102
+		t.Fatalf("marshal subscription raw: %v", err)
103
+	}
104
+	fake := &fakeStripeRemote{
105
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
106
+			return stripeapi.Event{
107
+				ID:         "evt_sub_active",
108
+				Type:       stripeapi.EventType("customer.subscription.updated"),
109
+				APIVersion: "2024-06-20",
110
+				Data:       &stripeapi.EventData{Raw: raw},
111
+			}, nil
112
+		},
113
+	}
114
+	mux := newOrgBillingMux(t, pool, ownerID, fake)
115
+
116
+	req := httptest.NewRequest(http.MethodPost, "/stripe/webhook", strings.NewReader(`{"id":"evt_sub_active"}`))
117
+	req.Header.Set("Stripe-Signature", "sig_test")
118
+	resp := httptest.NewRecorder()
119
+	mux.ServeHTTP(resp, req)
120
+	if resp.Code != http.StatusOK {
121
+		t.Fatalf("first webhook status=%d body=%s", resp.Code, resp.Body.String())
122
+	}
123
+	state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID)
124
+	if err != nil {
125
+		t.Fatalf("GetOrgBillingState: %v", err)
126
+	}
127
+	if state.Plan != orgbilling.PlanTeam || state.SubscriptionStatus != orgbilling.SubscriptionStatusActive {
128
+		t.Fatalf("unexpected billing state: %+v", state)
129
+	}
130
+	if !state.StripeCustomerID.Valid || state.StripeCustomerID.String != "cus_test_webhook" {
131
+		t.Fatalf("expected customer id saved, got %+v", state.StripeCustomerID)
132
+	}
133
+	if !state.StripeSubscriptionID.Valid || state.StripeSubscriptionID.String != "sub_test" {
134
+		t.Fatalf("expected subscription id saved, got %+v", state.StripeSubscriptionID)
135
+	}
136
+	receipt, err := billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_sub_active")
137
+	if err != nil {
138
+		t.Fatalf("GetWebhookEventReceipt: %v", err)
139
+	}
140
+	if !receipt.ProcessedAt.Valid || receipt.ProcessingAttempts != 1 {
141
+		t.Fatalf("unexpected receipt after first processing: %+v", receipt)
142
+	}
143
+
144
+	req = httptest.NewRequest(http.MethodPost, "/stripe/webhook", strings.NewReader(`{"id":"evt_sub_active"}`))
145
+	req.Header.Set("Stripe-Signature", "sig_test")
146
+	resp = httptest.NewRecorder()
147
+	mux.ServeHTTP(resp, req)
148
+	if resp.Code != http.StatusOK {
149
+		t.Fatalf("duplicate webhook status=%d body=%s", resp.Code, resp.Body.String())
150
+	}
151
+	receipt, err = billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_sub_active")
152
+	if err != nil {
153
+		t.Fatalf("GetWebhookEventReceipt duplicate: %v", err)
154
+	}
155
+	if receipt.ProcessingAttempts != 1 {
156
+		t.Fatalf("duplicate webhook should not reprocess receipt: %+v", receipt)
157
+	}
158
+}
159
+
160
+type fakeStripeRemote struct {
161
+	createCustomerFn func(context.Context, stripebilling.CustomerInput) (stripebilling.Customer, error)
162
+	createCheckoutFn func(context.Context, stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error)
163
+	createPortalFn   func(context.Context, stripebilling.PortalInput) (stripebilling.PortalSession, error)
164
+	updateQuantityFn func(context.Context, stripebilling.SeatQuantityInput) error
165
+	verifyWebhookFn  func([]byte, string) (stripeapi.Event, error)
166
+}
167
+
168
+func (f *fakeStripeRemote) CreateCustomer(ctx context.Context, in stripebilling.CustomerInput) (stripebilling.Customer, error) {
169
+	if f.createCustomerFn == nil {
170
+		return stripebilling.Customer{}, nil
171
+	}
172
+	return f.createCustomerFn(ctx, in)
173
+}
174
+
175
+func (f *fakeStripeRemote) CreateCheckoutSession(ctx context.Context, in stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error) {
176
+	if f.createCheckoutFn == nil {
177
+		return stripebilling.CheckoutSession{}, nil
178
+	}
179
+	return f.createCheckoutFn(ctx, in)
180
+}
181
+
182
+func (f *fakeStripeRemote) CreatePortalSession(ctx context.Context, in stripebilling.PortalInput) (stripebilling.PortalSession, error) {
183
+	if f.createPortalFn == nil {
184
+		return stripebilling.PortalSession{}, nil
185
+	}
186
+	return f.createPortalFn(ctx, in)
187
+}
188
+
189
+func (f *fakeStripeRemote) UpdateSubscriptionItemQuantity(ctx context.Context, in stripebilling.SeatQuantityInput) error {
190
+	if f.updateQuantityFn == nil {
191
+		return nil
192
+	}
193
+	return f.updateQuantityFn(ctx, in)
194
+}
195
+
196
+func (f *fakeStripeRemote) VerifyWebhook(payload []byte, signatureHeader string) (stripeapi.Event, error) {
197
+	if f.verifyWebhookFn == nil {
198
+		return stripeapi.Event{}, nil
199
+	}
200
+	return f.verifyWebhookFn(payload, signatureHeader)
201
+}
202
+
203
+func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote stripebilling.Remote) *chi.Mux {
204
+	t.Helper()
205
+	tmplFS := fstest.MapFS{
206
+		"_layout.html":               {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
207
+		"orgs/settings_billing.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Notice }}NOTICE={{ . }}{{ end }}{{ range .Invoices }}INVOICE={{ .Number }};{{ end }}{{ end }}`)},
208
+		"errors/403.html":            {Data: []byte(`{{ define "page" }}403{{ end }}`)},
209
+		"errors/404.html":            {Data: []byte(`{{ define "page" }}404{{ end }}`)},
210
+		"errors/500.html":            {Data: []byte(`{{ define "page" }}500{{ end }}`)},
211
+	}
212
+	rr, err := render.New(tmplFS, render.Options{})
213
+	if err != nil {
214
+		t.Fatalf("render.New: %v", err)
215
+	}
216
+	h, err := orgsh.New(orgsh.Deps{
217
+		Logger:                slog.New(slog.NewTextHandler(io.Discard, nil)),
218
+		Render:                rr,
219
+		Pool:                  pool,
220
+		BaseURL:               "https://shithub.example",
221
+		BillingEnabled:        true,
222
+		BillingGracePeriod:    14 * 24 * time.Hour,
223
+		Stripe:                remote,
224
+		StripeSuccessURL:      "https://shithub.example/organizations/{org}/billing/success",
225
+		StripeCancelURL:       "https://shithub.example/organizations/{org}/billing/cancel",
226
+		StripePortalReturnURL: "https://shithub.example/organizations/{org}/settings/billing",
227
+	})
228
+	if err != nil {
229
+		t.Fatalf("orgsh.New: %v", err)
230
+	}
231
+	mux := chi.NewRouter()
232
+	mux.Use(func(next http.Handler) http.Handler {
233
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234
+			viewer := middleware.CurrentUser{ID: ownerID, Username: "owner"}
235
+			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
236
+		})
237
+	})
238
+	h.MountCreate(mux)
239
+	h.MountBillingWebhook(mux)
240
+	return mux
241
+}
internal/web/handlers/orgs/billing_webhook.goadded
@@ -0,0 +1,361 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"errors"
9
+	"fmt"
10
+	"io"
11
+	"net/http"
12
+	"strconv"
13
+	"strings"
14
+	"time"
15
+
16
+	"github.com/jackc/pgx/v5"
17
+	stripeapi "github.com/stripe/stripe-go/v85"
18
+
19
+	orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
20
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
21
+)
22
+
23
+const stripeWebhookBodyLimit = 1 << 20
24
+
25
+func (h *Handlers) billingWebhook(w http.ResponseWriter, r *http.Request) {
26
+	if !h.billingConfigured() {
27
+		http.NotFound(w, r)
28
+		return
29
+	}
30
+	r.Body = http.MaxBytesReader(w, r.Body, stripeWebhookBodyLimit)
31
+	payload, err := io.ReadAll(r.Body)
32
+	if err != nil {
33
+		http.Error(w, "invalid webhook body", http.StatusBadRequest)
34
+		return
35
+	}
36
+	event, err := h.d.Stripe.VerifyWebhook(payload, r.Header.Get("Stripe-Signature"))
37
+	if err != nil {
38
+		http.Error(w, "invalid stripe signature", http.StatusBadRequest)
39
+		return
40
+	}
41
+	receipt, created, err := orgbilling.RecordWebhookEvent(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, orgbilling.WebhookEvent{
42
+		ProviderEventID: event.ID,
43
+		EventType:       string(event.Type),
44
+		APIVersion:      event.APIVersion,
45
+		Payload:         payload,
46
+	})
47
+	if err != nil {
48
+		h.d.Logger.ErrorContext(r.Context(), "org billing: record webhook receipt", "event_id", event.ID, "event_type", event.Type, "error", err)
49
+		http.Error(w, "could not record webhook receipt", http.StatusInternalServerError)
50
+		return
51
+	}
52
+	if !created && receipt.ProcessedAt.Valid {
53
+		w.WriteHeader(http.StatusOK)
54
+		_, _ = w.Write([]byte("ok"))
55
+		return
56
+	}
57
+	if err := h.processStripeWebhook(r.Context(), event); err != nil {
58
+		h.d.Logger.ErrorContext(r.Context(), "org billing: process webhook", "event_id", event.ID, "event_type", event.Type, "error", err)
59
+		if _, markErr := orgbilling.MarkWebhookEventFailed(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, event.ID, err.Error()); markErr != nil {
60
+			h.d.Logger.ErrorContext(r.Context(), "org billing: mark webhook failed", "event_id", event.ID, "error", markErr)
61
+		}
62
+		http.Error(w, "webhook processing failed", http.StatusInternalServerError)
63
+		return
64
+	}
65
+	if _, err := orgbilling.MarkWebhookEventProcessed(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, event.ID); err != nil {
66
+		h.d.Logger.ErrorContext(r.Context(), "org billing: mark webhook processed", "event_id", event.ID, "error", err)
67
+		http.Error(w, "could not finalize webhook receipt", http.StatusInternalServerError)
68
+		return
69
+	}
70
+	w.WriteHeader(http.StatusOK)
71
+	_, _ = w.Write([]byte("ok"))
72
+}
73
+
74
+func (h *Handlers) processStripeWebhook(ctx context.Context, event stripeapi.Event) error {
75
+	switch string(event.Type) {
76
+	case "checkout.session.completed":
77
+		return h.applyStripeCheckoutCompleted(ctx, event)
78
+	case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted":
79
+		return h.applyStripeSubscriptionEvent(ctx, event)
80
+	case "invoice.payment_succeeded", "invoice.payment_failed", "invoice.voided", "invoice.marked_uncollectible":
81
+		return h.applyStripeInvoiceEvent(ctx, event)
82
+	default:
83
+		return nil
84
+	}
85
+}
86
+
87
+func (h *Handlers) applyStripeCheckoutCompleted(ctx context.Context, event stripeapi.Event) error {
88
+	var session stripeapi.CheckoutSession
89
+	if err := unmarshalStripeEventObject(event, &session); err != nil {
90
+		return err
91
+	}
92
+	orgID := stripeOrgIDFromMetadata(session.Metadata)
93
+	if orgID == 0 {
94
+		if id, err := strconv.ParseInt(strings.TrimSpace(session.ClientReferenceID), 10, 64); err == nil && id > 0 {
95
+			orgID = id
96
+		}
97
+	}
98
+	if orgID == 0 {
99
+		return errors.New("stripe checkout.session.completed missing shithub org metadata")
100
+	}
101
+	customerID := stripeCustomerID(session.Customer)
102
+	if customerID == "" {
103
+		return errors.New("stripe checkout.session.completed missing customer")
104
+	}
105
+	_, err := orgbilling.SetStripeCustomer(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID, customerID)
106
+	return err
107
+}
108
+
109
+func (h *Handlers) applyStripeSubscriptionEvent(ctx context.Context, event stripeapi.Event) error {
110
+	var sub stripeapi.Subscription
111
+	if err := unmarshalStripeEventObject(event, &sub); err != nil {
112
+		return err
113
+	}
114
+	orgID, err := h.resolveOrgIDFromSubscription(ctx, &sub)
115
+	if err != nil {
116
+		return err
117
+	}
118
+	customerID := stripeCustomerID(sub.Customer)
119
+	if customerID != "" {
120
+		if _, err := orgbilling.SetStripeCustomer(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID, customerID); err != nil {
121
+			return err
122
+		}
123
+	}
124
+	status, err := stripeSubscriptionStatus(sub.Status)
125
+	if err != nil {
126
+		return err
127
+	}
128
+	if status == orgbilling.SubscriptionStatusCanceled || string(event.Type) == "customer.subscription.deleted" {
129
+		_, err := orgbilling.MarkCanceled(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID, event.ID)
130
+		return err
131
+	}
132
+	itemID := stripeSubscriptionItemID(sub.Items)
133
+	periodStart, periodEnd := stripeSubscriptionPeriod(sub.Items)
134
+	_, err = orgbilling.ApplySubscriptionSnapshot(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgbilling.SubscriptionSnapshot{
135
+		OrgID:                    orgID,
136
+		Plan:                     orgbilling.PlanTeam,
137
+		Status:                   status,
138
+		StripeSubscriptionID:     strings.TrimSpace(sub.ID),
139
+		StripeSubscriptionItemID: itemID,
140
+		CurrentPeriodStart:       periodStart,
141
+		CurrentPeriodEnd:         periodEnd,
142
+		CancelAtPeriodEnd:        sub.CancelAtPeriodEnd,
143
+		TrialEnd:                 unixTime(sub.TrialEnd),
144
+		CanceledAt:               unixTime(sub.CanceledAt),
145
+		LastWebhookEventID:       event.ID,
146
+	})
147
+	return err
148
+}
149
+
150
+func (h *Handlers) applyStripeInvoiceEvent(ctx context.Context, event stripeapi.Event) error {
151
+	var inv stripeapi.Invoice
152
+	if err := unmarshalStripeEventObject(event, &inv); err != nil {
153
+		return err
154
+	}
155
+	orgID, state, err := h.resolveOrgStateFromInvoice(ctx, &inv)
156
+	if err != nil {
157
+		return err
158
+	}
159
+	status, err := stripeInvoiceStatus(inv.Status)
160
+	if err != nil {
161
+		return err
162
+	}
163
+	if _, err := orgbilling.UpsertInvoice(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgbilling.InvoiceSnapshot{
164
+		OrgID:                orgID,
165
+		StripeInvoiceID:      strings.TrimSpace(inv.ID),
166
+		StripeCustomerID:     stripeCustomerID(inv.Customer),
167
+		StripeSubscriptionID: stripeInvoiceSubscriptionID(&inv),
168
+		Status:               status,
169
+		Number:               strings.TrimSpace(inv.Number),
170
+		Currency:             strings.ToLower(string(inv.Currency)),
171
+		AmountDueCents:       inv.AmountDue,
172
+		AmountPaidCents:      inv.AmountPaid,
173
+		AmountRemainingCents: inv.AmountRemaining,
174
+		HostedInvoiceURL:     strings.TrimSpace(inv.HostedInvoiceURL),
175
+		InvoicePDFURL:        strings.TrimSpace(inv.InvoicePDF),
176
+		PeriodStart:          unixTime(inv.PeriodStart),
177
+		PeriodEnd:            unixTime(inv.PeriodEnd),
178
+		DueAt:                unixTime(inv.DueDate),
179
+		PaidAt:               unixTime(stripeInvoicePaidAt(inv.StatusTransitions)),
180
+		VoidedAt:             unixTime(stripeInvoiceVoidedAt(inv.StatusTransitions)),
181
+	}); err != nil {
182
+		return err
183
+	}
184
+	switch string(event.Type) {
185
+	case "invoice.payment_failed":
186
+		graceUntil := time.Now().UTC().Add(h.d.BillingGracePeriod)
187
+		_, err := orgbilling.MarkPastDue(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID, graceUntil, event.ID)
188
+		return err
189
+	case "invoice.payment_succeeded":
190
+		if state.SubscriptionStatus != orgbilling.SubscriptionStatusCanceled {
191
+			_, err := orgbilling.ClearBillingLock(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID)
192
+			return err
193
+		}
194
+	}
195
+	return nil
196
+}
197
+
198
+func (h *Handlers) resolveOrgIDFromSubscription(ctx context.Context, sub *stripeapi.Subscription) (int64, error) {
199
+	if orgID := stripeOrgIDFromMetadata(sub.Metadata); orgID != 0 {
200
+		return orgID, nil
201
+	}
202
+	if customerID := stripeCustomerID(sub.Customer); customerID != "" {
203
+		state, err := orgbilling.GetOrgBillingStateByStripeCustomer(ctx, orgbilling.Deps{Pool: h.d.Pool}, customerID)
204
+		if err == nil {
205
+			return state.OrgID, nil
206
+		}
207
+		if !errors.Is(err, pgx.ErrNoRows) {
208
+			return 0, err
209
+		}
210
+	}
211
+	if subID := strings.TrimSpace(sub.ID); subID != "" {
212
+		state, err := orgbilling.GetOrgBillingStateByStripeSubscription(ctx, orgbilling.Deps{Pool: h.d.Pool}, subID)
213
+		if err == nil {
214
+			return state.OrgID, nil
215
+		}
216
+		if !errors.Is(err, pgx.ErrNoRows) {
217
+			return 0, err
218
+		}
219
+	}
220
+	return 0, errors.New("stripe subscription does not map to a shithub organization")
221
+}
222
+
223
+func (h *Handlers) resolveOrgStateFromInvoice(ctx context.Context, inv *stripeapi.Invoice) (int64, orgbilling.State, error) {
224
+	if customerID := stripeCustomerID(inv.Customer); customerID != "" {
225
+		state, err := orgbilling.GetOrgBillingStateByStripeCustomer(ctx, orgbilling.Deps{Pool: h.d.Pool}, customerID)
226
+		if err == nil {
227
+			return state.OrgID, state, nil
228
+		}
229
+		if !errors.Is(err, pgx.ErrNoRows) {
230
+			return 0, orgbilling.State{}, err
231
+		}
232
+	}
233
+	if subID := stripeInvoiceSubscriptionID(inv); subID != "" {
234
+		state, err := orgbilling.GetOrgBillingStateByStripeSubscription(ctx, orgbilling.Deps{Pool: h.d.Pool}, subID)
235
+		if err == nil {
236
+			return state.OrgID, state, nil
237
+		}
238
+		if !errors.Is(err, pgx.ErrNoRows) {
239
+			return 0, orgbilling.State{}, err
240
+		}
241
+	}
242
+	return 0, orgbilling.State{}, errors.New("stripe invoice does not map to a shithub organization")
243
+}
244
+
245
+func stripeOrgIDFromMetadata(metadata map[string]string) int64 {
246
+	raw := strings.TrimSpace(metadata[stripebilling.MetadataOrgID])
247
+	if raw == "" {
248
+		return 0
249
+	}
250
+	n, err := strconv.ParseInt(raw, 10, 64)
251
+	if err != nil || n <= 0 {
252
+		return 0
253
+	}
254
+	return n
255
+}
256
+
257
+func stripeCustomerID(customer *stripeapi.Customer) string {
258
+	if customer == nil {
259
+		return ""
260
+	}
261
+	return strings.TrimSpace(customer.ID)
262
+}
263
+
264
+func stripeSubscriptionID(sub *stripeapi.Subscription) string {
265
+	if sub == nil {
266
+		return ""
267
+	}
268
+	return strings.TrimSpace(sub.ID)
269
+}
270
+
271
+func stripeSubscriptionItemID(items *stripeapi.SubscriptionItemList) string {
272
+	if items == nil || len(items.Data) == 0 || items.Data[0] == nil {
273
+		return ""
274
+	}
275
+	return strings.TrimSpace(items.Data[0].ID)
276
+}
277
+
278
+func stripeSubscriptionPeriod(items *stripeapi.SubscriptionItemList) (time.Time, time.Time) {
279
+	if items == nil || len(items.Data) == 0 || items.Data[0] == nil {
280
+		return time.Time{}, time.Time{}
281
+	}
282
+	return unixTime(items.Data[0].CurrentPeriodStart), unixTime(items.Data[0].CurrentPeriodEnd)
283
+}
284
+
285
+func stripeInvoiceSubscriptionID(inv *stripeapi.Invoice) string {
286
+	if inv == nil || inv.Parent == nil || inv.Parent.SubscriptionDetails == nil || inv.Parent.SubscriptionDetails.Subscription == nil {
287
+		return ""
288
+	}
289
+	return strings.TrimSpace(inv.Parent.SubscriptionDetails.Subscription.ID)
290
+}
291
+
292
+func stripeSubscriptionStatus(status stripeapi.SubscriptionStatus) (orgbilling.SubscriptionStatus, error) {
293
+	switch string(status) {
294
+	case "incomplete":
295
+		return orgbilling.SubscriptionStatusIncomplete, nil
296
+	case "trialing":
297
+		return orgbilling.SubscriptionStatusTrialing, nil
298
+	case "active":
299
+		return orgbilling.SubscriptionStatusActive, nil
300
+	case "past_due":
301
+		return orgbilling.SubscriptionStatusPastDue, nil
302
+	case "canceled":
303
+		return orgbilling.SubscriptionStatusCanceled, nil
304
+	case "unpaid":
305
+		return orgbilling.SubscriptionStatusUnpaid, nil
306
+	case "paused":
307
+		return orgbilling.SubscriptionStatusPaused, nil
308
+	case "incomplete_expired":
309
+		return orgbilling.SubscriptionStatusCanceled, nil
310
+	default:
311
+		return "", fmt.Errorf("unsupported stripe subscription status %q", status)
312
+	}
313
+}
314
+
315
+func stripeInvoiceStatus(status stripeapi.InvoiceStatus) (orgbilling.InvoiceStatus, error) {
316
+	switch string(status) {
317
+	case "draft":
318
+		return orgbilling.InvoiceStatusDraft, nil
319
+	case "open":
320
+		return orgbilling.InvoiceStatusOpen, nil
321
+	case "paid":
322
+		return orgbilling.InvoiceStatusPaid, nil
323
+	case "void":
324
+		return orgbilling.InvoiceStatusVoid, nil
325
+	case "uncollectible":
326
+		return orgbilling.InvoiceStatusUncollectible, nil
327
+	default:
328
+		return "", fmt.Errorf("unsupported stripe invoice status %q", status)
329
+	}
330
+}
331
+
332
+func stripeInvoicePaidAt(transitions *stripeapi.InvoiceStatusTransitions) int64 {
333
+	if transitions == nil {
334
+		return 0
335
+	}
336
+	return transitions.PaidAt
337
+}
338
+
339
+func stripeInvoiceVoidedAt(transitions *stripeapi.InvoiceStatusTransitions) int64 {
340
+	if transitions == nil {
341
+		return 0
342
+	}
343
+	return transitions.VoidedAt
344
+}
345
+
346
+func unixTime(ts int64) time.Time {
347
+	if ts <= 0 {
348
+		return time.Time{}
349
+	}
350
+	return time.Unix(ts, 0).UTC()
351
+}
352
+
353
+func unmarshalStripeEventObject[T any](event stripeapi.Event, out *T) error {
354
+	if event.Data == nil || len(event.Data.Raw) == 0 {
355
+		return errors.New("stripe webhook missing event data")
356
+	}
357
+	if err := json.Unmarshal(event.Data.Raw, out); err != nil {
358
+		return fmt.Errorf("decode stripe %s event: %w", event.Type, err)
359
+	}
360
+	return nil
361
+}
internal/web/handlers/orgs/imports.gomodified
@@ -118,6 +118,8 @@ func (h *Handlers) renderSettingsImport(w http.ResponseWriter, r *http.Request,
118118
 		"Org":               org,
119119
 		"AvatarURL":         "/avatars/" + url.PathEscape(org.Slug),
120120
 		"ActiveOrgNav":      "settings",
121
+		"OrgSettingsActive": "import",
122
+		"BillingEnabled":    h.d.BillingEnabled,
121123
 		"Form":              form,
122124
 		"Error":             errMsg,
123125
 		"Notice":            notice,
internal/web/handlers/orgs/orgs.gomodified
@@ -32,6 +32,7 @@ import (
3232
 	"net/url"
3333
 	"strconv"
3434
 	"strings"
35
+	"time"
3536
 
3637
 	"github.com/go-chi/chi/v5"
3738
 	"github.com/jackc/pgx/v5/pgxpool"
@@ -39,6 +40,7 @@ import (
3940
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
4041
 	authemail "github.com/tenseleyFlow/shithub/internal/auth/email"
4142
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
43
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
4244
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
4345
 	"github.com/tenseleyFlow/shithub/internal/orgs"
4446
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
@@ -58,6 +60,12 @@ type Deps struct {
5860
 	ObjectStore           storage.ObjectStore
5961
 	SecretBox             *secretbox.Box
6062
 	Audit                 *audit.Recorder
63
+	BillingEnabled        bool
64
+	BillingGracePeriod    time.Duration
65
+	Stripe                stripebilling.Remote
66
+	StripeSuccessURL      string
67
+	StripeCancelURL       string
68
+	StripePortalReturnURL string
6169
 }
6270
 
6371
 // Handlers groups the org surface handlers.
@@ -101,6 +109,13 @@ func (h *Handlers) MountCreate(r chi.Router) {
101109
 	r.Get("/organizations/{org}/settings/variables/actions", h.settingsActionsVariables)
102110
 	r.Post("/organizations/{org}/settings/variables/actions", h.settingsActionsVariableSet)
103111
 	r.Post("/organizations/{org}/settings/variables/actions/{name}/delete", h.settingsActionsVariableDelete)
112
+	if h.billingConfigured() {
113
+		r.Get("/organizations/{org}/settings/billing", h.settingsBilling)
114
+		r.Post("/organizations/{org}/billing/checkout", h.billingCheckout)
115
+		r.Post("/organizations/{org}/billing/portal", h.billingPortal)
116
+		r.Get("/organizations/{org}/billing/success", h.billingSuccess)
117
+		r.Get("/organizations/{org}/billing/cancel", h.billingCancel)
118
+	}
104119
 }
105120
 
106121
 // MountOrgRoutes registers the per-org surface under /{org}/people
@@ -126,6 +141,13 @@ func (h *Handlers) MountInvitations(r chi.Router) {
126141
 	r.Post("/invitations/{token}/decline", h.invitationDecline)
127142
 }
128143
 
144
+func (h *Handlers) MountBillingWebhook(r chi.Router) {
145
+	if !h.billingConfigured() {
146
+		return
147
+	}
148
+	r.Post("/stripe/webhook", h.billingWebhook)
149
+}
150
+
129151
 // ─── helpers ───────────────────────────────────────────────────────
130152
 
131153
 func (h *Handlers) deps() orgs.Deps {
@@ -139,6 +161,10 @@ func (h *Handlers) deps() orgs.Deps {
139161
 	}
140162
 }
141163
 
164
+func (h *Handlers) billingConfigured() bool {
165
+	return h.d.BillingEnabled && h.d.Stripe != nil
166
+}
167
+
142168
 // orgFromSlug resolves the org from a {org} URL param, with an
143169
 // existence-leak-safe 404 path.
144170
 func (h *Handlers) orgFromSlug(w http.ResponseWriter, r *http.Request) (orgsdb.Org, bool) {
internal/web/handlers/orgs/settings_profile.gomodified
@@ -158,6 +158,8 @@ func (h *Handlers) renderSettingsProfileWithForm(
158158
 		"Form":                form,
159159
 		"AvatarURL":           "/avatars/" + url.PathEscape(org.Slug),
160160
 		"ActiveOrgNav":        "settings",
161
+		"OrgSettingsActive":   "profile",
162
+		"BillingEnabled":      h.d.BillingEnabled,
161163
 		"AvatarUploadEnabled": h.d.ObjectStore != nil,
162164
 		"HasAvatar":           org.AvatarObjectKey.Valid && org.AvatarObjectKey.String != "",
163165
 		"Error":               errMsg,
internal/web/orgs_wiring.gomodified
@@ -15,6 +15,7 @@ import (
1515
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
1616
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
1717
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
18
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
1819
 	"github.com/tenseleyFlow/shithub/internal/infra/config"
1920
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2021
 	orgshandlers "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs"
@@ -34,6 +35,19 @@ func buildOrgHandlers(
3435
 	if err != nil {
3536
 		return nil, err
3637
 	}
38
+	var stripeRemote stripebilling.Remote
39
+	if cfg.Billing.Enabled {
40
+		remote, err := stripebilling.New(stripebilling.Config{
41
+			SecretKey:     cfg.Billing.Stripe.SecretKey,
42
+			WebhookSecret: cfg.Billing.Stripe.WebhookSecret,
43
+			TeamPriceID:   cfg.Billing.Stripe.TeamPriceID,
44
+			AutomaticTax:  cfg.Billing.Stripe.AutomaticTax,
45
+		})
46
+		if err != nil {
47
+			return nil, err
48
+		}
49
+		stripeRemote = remote
50
+	}
3751
 	sender, _ := pickOrgsEmailSender(cfg)
3852
 	var box *secretbox.Box
3953
 	if cfg.Auth.TOTPKeyB64 != "" {
@@ -56,6 +70,12 @@ func buildOrgHandlers(
5670
 		ObjectStore:           objectStore,
5771
 		SecretBox:             box,
5872
 		Audit:                 audit.NewRecorder(),
73
+		BillingEnabled:        cfg.Billing.Enabled,
74
+		BillingGracePeriod:    cfg.Billing.GracePeriod,
75
+		Stripe:                stripeRemote,
76
+		StripeSuccessURL:      cfg.Billing.Stripe.SuccessURL,
77
+		StripeCancelURL:       cfg.Billing.Stripe.CancelURL,
78
+		StripePortalReturnURL: cfg.Billing.Stripe.PortalReturnURL,
5979
 	})
6080
 }
6181
 
internal/web/server.gomodified
@@ -285,6 +285,9 @@ func Run(ctx context.Context, opts Options) error {
285285
 		if err != nil {
286286
 			return fmt.Errorf("org handlers: %w", err)
287287
 		}
288
+		if cfg.Billing.Enabled {
289
+			deps.BillingWebhookMounter = orgH.MountBillingWebhook
290
+		}
288291
 		deps.OrgCreateMounter = func(r chi.Router) {
289292
 			r.Group(func(r chi.Router) {
290293
 				r.Use(middleware.RequireUser)
internal/web/static/css/shithub.cssmodified
@@ -3967,6 +3967,84 @@ button.shithub-contrib-setting-item:hover {
39673967
   margin: 0.15rem 0 0;
39683968
   color: var(--fg-muted);
39693969
 }
3970
+.shithub-org-billing-summary {
3971
+  display: grid;
3972
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
3973
+  gap: 1rem;
3974
+}
3975
+.shithub-org-billing-card {
3976
+  min-height: 100%;
3977
+}
3978
+.shithub-org-billing-card .Box-body {
3979
+  display: grid;
3980
+  gap: 0.35rem;
3981
+}
3982
+.shithub-org-billing-card-value {
3983
+  margin: 0;
3984
+  font-size: 1.5rem;
3985
+  font-weight: 600;
3986
+  line-height: 1.2;
3987
+}
3988
+.shithub-org-billing-card-detail {
3989
+  margin: 0;
3990
+  color: var(--fg-muted);
3991
+  font-size: 0.875rem;
3992
+}
3993
+.shithub-org-billing-table {
3994
+  width: 100%;
3995
+  border-collapse: collapse;
3996
+}
3997
+.shithub-org-billing-table th,
3998
+.shithub-org-billing-table td {
3999
+  padding: 0.75rem 1rem;
4000
+  border-top: 1px solid var(--border-muted, var(--border-default));
4001
+  text-align: left;
4002
+  vertical-align: top;
4003
+}
4004
+.shithub-org-billing-table thead th {
4005
+  border-top: 0;
4006
+  color: var(--fg-muted);
4007
+  font-size: 0.75rem;
4008
+  font-weight: 600;
4009
+  text-transform: uppercase;
4010
+}
4011
+.shithub-org-billing-links {
4012
+  white-space: nowrap;
4013
+}
4014
+.shithub-org-billing-links a + a {
4015
+  margin-left: 0.65rem;
4016
+}
4017
+.shithub-org-billing-status {
4018
+  display: inline-flex;
4019
+  align-items: center;
4020
+  min-height: 1.5rem;
4021
+  padding: 0 0.5rem;
4022
+  border-radius: 999px;
4023
+  font-size: 0.75rem;
4024
+  font-weight: 600;
4025
+  text-transform: uppercase;
4026
+  letter-spacing: 0;
4027
+  background: var(--canvas-subtle);
4028
+  color: var(--fg-default);
4029
+}
4030
+.shithub-org-billing-status.is-paid,
4031
+.shithub-org-billing-status.is-active {
4032
+  background: rgba(35, 134, 54, 0.18);
4033
+  color: var(--success-fg);
4034
+}
4035
+.shithub-org-billing-status.is-open,
4036
+.shithub-org-billing-status.is-draft {
4037
+  background: rgba(9, 105, 218, 0.16);
4038
+  color: var(--accent-fg);
4039
+}
4040
+.shithub-org-billing-status.is-uncollectible,
4041
+.shithub-org-billing-status.is-void,
4042
+.shithub-org-billing-status.is-past-due,
4043
+.shithub-org-billing-status.is-unpaid,
4044
+.shithub-org-billing-status.is-canceled {
4045
+  background: rgba(218, 54, 51, 0.16);
4046
+  color: var(--danger-fg);
4047
+}
39704048
 .shithub-org-import-create {
39714049
   display: grid;
39724050
   gap: 0.75rem;
internal/web/templates/_org_settings_nav.htmladded
@@ -0,0 +1,27 @@
1
+{{ define "org-settings-nav" -}}
2
+<aside class="shithub-org-settings-sidebar" aria-label="Organization settings">
3
+  <h1>Settings</h1>
4
+  <nav class="shithub-org-settings-menu">
5
+    <a{{ if eq .OrgSettingsActive "profile" }} class="is-selected" aria-current="page"{{ end }} href="/organizations/{{ .Org.Slug }}/settings/profile">{{ octicon "organization" }} General</a>
6
+    <a{{ if eq .OrgSettingsActive "import" }} class="is-selected" aria-current="page"{{ end }} href="/organizations/{{ .Org.Slug }}/settings/import">{{ octicon "repo" }} Import repositories</a>
7
+    <span aria-disabled="true">{{ octicon "people" }} People</span>
8
+    <span aria-disabled="true">{{ octicon "repo" }} Repository defaults</span>
9
+    <span aria-disabled="true">{{ octicon "lock" }} Member privileges</span>
10
+    <h2>Code, planning, and automation</h2>
11
+    <span aria-disabled="true">{{ octicon "play" }} Actions</span>
12
+    <span aria-disabled="true">{{ octicon "table" }} Projects</span>
13
+    <span aria-disabled="true">{{ octicon "package" }} Packages</span>
14
+    <h2>Security</h2>
15
+    <span aria-disabled="true">{{ octicon "shield-check" }} Code security</span>
16
+    <span aria-disabled="true">{{ octicon "globe" }} Domains</span>
17
+    <h2>Access</h2>
18
+    {{ if .BillingEnabled }}
19
+    <a{{ if eq .OrgSettingsActive "billing" }} class="is-selected" aria-current="page"{{ end }} href="/organizations/{{ .Org.Slug }}/settings/billing">{{ octicon "credit-card" }} Billing and plans</a>
20
+    {{ else }}
21
+    <span aria-disabled="true">{{ octicon "credit-card" }} Billing and plans</span>
22
+    {{ end }}
23
+    <span aria-disabled="true">{{ octicon "gear" }} Integrations</span>
24
+    <span aria-disabled="true">{{ octicon "history" }} Audit log</span>
25
+  </nav>
26
+</aside>
27
+{{- end }}
internal/web/templates/orgs/settings_billing.htmladded
@@ -0,0 +1,124 @@
1
+{{ define "page" -}}
2
+<section class="shithub-org-settings-page">
3
+  <header class="shithub-org-pagehead shithub-org-settings-pagehead">
4
+    <div class="shithub-org-pagehead-inner">
5
+      <a class="shithub-org-pagehead-title" href="/{{ .Org.Slug }}">
6
+        <img src="{{ .AvatarURL }}" alt="" width="30" height="30">
7
+        <span>{{ if .Org.DisplayName }}{{ .Org.DisplayName }}{{ else }}{{ .Org.Slug }}{{ end }}</span>
8
+      </a>
9
+    </div>
10
+  </header>
11
+
12
+  <div class="shithub-org-settings-layout">
13
+    {{ template "org-settings-nav" . }}
14
+
15
+    <div class="shithub-org-settings-main">
16
+      {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
17
+      {{ with .Notice }}<p class="shithub-flash shithub-flash-success" role="status">{{ . }}</p>{{ end }}
18
+
19
+      <div class="Subhead">
20
+        <h2 class="Subhead-heading Subhead-heading--large">Billing and plans</h2>
21
+      </div>
22
+
23
+      <section class="shithub-org-settings-section" aria-labelledby="org-billing-summary-heading">
24
+        <h3 id="org-billing-summary-heading" class="sr-only">Billing summary</h3>
25
+        <div class="shithub-org-billing-summary">
26
+          {{ range .Summary }}
27
+          <section class="Box shithub-org-billing-card">
28
+            <div class="Box-header">
29
+              <h4 class="Box-title">{{ .Label }}</h4>
30
+            </div>
31
+            <div class="Box-body">
32
+              <p class="shithub-org-billing-card-value">{{ .Value }}</p>
33
+              {{ with .Detail }}<p class="shithub-org-billing-card-detail">{{ . }}</p>{{ end }}
34
+            </div>
35
+          </section>
36
+          {{ end }}
37
+        </div>
38
+      </section>
39
+
40
+      <section class="shithub-org-settings-section" aria-labelledby="org-billing-manage-heading">
41
+        <div class="Subhead Subhead--spacious border-bottom-0 mb-0 tmp-mb-0">
42
+          <h2 id="org-billing-manage-heading" class="Subhead-heading">Manage plan</h2>
43
+        </div>
44
+        <div class="shithub-org-settings-box">
45
+          <div class="shithub-org-settings-row">
46
+            <div>
47
+              <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>
49
+            </div>
50
+            {{ if .CanManageSubscription }}
51
+            <form method="POST" action="/organizations/{{ .Org.Slug }}/billing/portal">
52
+              <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
53
+              <button type="submit" class="shithub-button">Manage billing</button>
54
+            </form>
55
+            {{ else if .CanStartCheckout }}
56
+            <form method="POST" action="/organizations/{{ .Org.Slug }}/billing/checkout">
57
+              <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
58
+              <button type="submit" class="shithub-button shithub-button-primary">Upgrade to Team</button>
59
+            </form>
60
+            {{ else }}
61
+            <span class="shithub-empty-note">Billing is not configured on this instance.</span>
62
+            {{ end }}
63
+          </div>
64
+          <div class="shithub-org-settings-row">
65
+            <div>
66
+              <strong>Billing email</strong>
67
+              <p>Stripe customer and invoice notifications use the organization billing email when one is set.</p>
68
+            </div>
69
+            <span>{{ if .Org.BillingEmail }}{{ .Org.BillingEmail }}{{ else }}Not set{{ end }}</span>
70
+          </div>
71
+          <div class="shithub-org-settings-row">
72
+            <div>
73
+              <strong>Payment failures</strong>
74
+              <p>Past-due subscriptions enter a grace period before paid entitlements are cut off.</p>
75
+            </div>
76
+            <span>{{ .GracePeriodLabel }}</span>
77
+          </div>
78
+        </div>
79
+      </section>
80
+
81
+      <section class="shithub-org-settings-section" aria-labelledby="org-billing-invoices-heading">
82
+        <div class="Subhead Subhead--spacious border-bottom-0 mb-0 tmp-mb-0">
83
+          <h2 id="org-billing-invoices-heading" class="Subhead-heading">Recent invoices</h2>
84
+        </div>
85
+        {{ if .Invoices }}
86
+        <div class="Box">
87
+          <div class="Box-body p-0">
88
+            <table class="shithub-org-billing-table">
89
+              <thead>
90
+                <tr>
91
+                  <th scope="col">Invoice</th>
92
+                  <th scope="col">Status</th>
93
+                  <th scope="col">Amount</th>
94
+                  <th scope="col">Period</th>
95
+                  <th scope="col">Due</th>
96
+                  <th scope="col"></th>
97
+                </tr>
98
+              </thead>
99
+              <tbody>
100
+                {{ range .Invoices }}
101
+                <tr>
102
+                  <td>{{ .Number }}</td>
103
+                  <td><span class="shithub-org-billing-status is-{{ .StatusClass }}">{{ .StatusLabel }}</span></td>
104
+                  <td>{{ .AmountLabel }}</td>
105
+                  <td>{{ .PeriodLabel }}</td>
106
+                  <td>{{ .DueLabel }}</td>
107
+                  <td class="shithub-org-billing-links">
108
+                    {{ if .HostedInvoiceURL }}<a href="{{ .HostedInvoiceURL }}" target="_blank" rel="noreferrer">Open</a>{{ end }}
109
+                    {{ if .InvoicePDFURL }}<a href="{{ .InvoicePDFURL }}" target="_blank" rel="noreferrer">PDF</a>{{ end }}
110
+                  </td>
111
+                </tr>
112
+                {{ end }}
113
+              </tbody>
114
+            </table>
115
+          </div>
116
+        </div>
117
+        {{ else }}
118
+        <p class="shithub-empty-note">No invoices have been recorded for this organization yet.</p>
119
+        {{ end }}
120
+      </section>
121
+    </div>
122
+  </div>
123
+</section>
124
+{{- end }}
internal/web/templates/orgs/settings_import.htmlmodified
@@ -10,26 +10,7 @@
1010
   </header>
1111
 
1212
   <div class="shithub-org-settings-layout">
13
-    <aside class="shithub-org-settings-sidebar" aria-label="Organization settings">
14
-      <h1>Settings</h1>
15
-      <nav class="shithub-org-settings-menu">
16
-        <a href="/organizations/{{ .Org.Slug }}/settings/profile">{{ octicon "organization" }} General</a>
17
-        <a class="is-selected" href="/organizations/{{ .Org.Slug }}/settings/import" aria-current="page">{{ octicon "repo" }} Import repositories</a>
18
-        <span aria-disabled="true">{{ octicon "people" }} People</span>
19
-        <span aria-disabled="true">{{ octicon "repo" }} Repository defaults</span>
20
-        <span aria-disabled="true">{{ octicon "lock" }} Member privileges</span>
21
-        <h2>Code, planning, and automation</h2>
22
-        <span aria-disabled="true">{{ octicon "play" }} Actions</span>
23
-        <span aria-disabled="true">{{ octicon "table" }} Projects</span>
24
-        <span aria-disabled="true">{{ octicon "package" }} Packages</span>
25
-        <h2>Security</h2>
26
-        <span aria-disabled="true">{{ octicon "shield-check" }} Code security</span>
27
-        <span aria-disabled="true">{{ octicon "globe" }} Domains</span>
28
-        <h2>Access</h2>
29
-        <span aria-disabled="true">{{ octicon "gear" }} Integrations</span>
30
-        <span aria-disabled="true">{{ octicon "history" }} Audit log</span>
31
-      </nav>
32
-    </aside>
13
+    {{ template "org-settings-nav" . }}
3314
 
3415
     <div class="shithub-org-settings-main">
3516
       {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
internal/web/templates/orgs/settings_profile.htmlmodified
@@ -10,26 +10,7 @@
1010
   </header>
1111
 
1212
   <div class="shithub-org-settings-layout">
13
-    <aside class="shithub-org-settings-sidebar" aria-label="Organization settings">
14
-      <h1>Settings</h1>
15
-      <nav class="shithub-org-settings-menu">
16
-        <a class="is-selected" href="/organizations/{{ .Org.Slug }}/settings/profile" aria-current="page">{{ octicon "organization" }} General</a>
17
-        <a href="/organizations/{{ .Org.Slug }}/settings/import">{{ octicon "repo" }} Import repositories</a>
18
-        <span aria-disabled="true">{{ octicon "people" }} People</span>
19
-        <span aria-disabled="true">{{ octicon "repo" }} Repository defaults</span>
20
-        <span aria-disabled="true">{{ octicon "lock" }} Member privileges</span>
21
-        <h2>Code, planning, and automation</h2>
22
-        <span aria-disabled="true">{{ octicon "play" }} Actions</span>
23
-        <span aria-disabled="true">{{ octicon "table" }} Projects</span>
24
-        <span aria-disabled="true">{{ octicon "package" }} Packages</span>
25
-        <h2>Security</h2>
26
-        <span aria-disabled="true">{{ octicon "shield-check" }} Code security</span>
27
-        <span aria-disabled="true">{{ octicon "globe" }} Domains</span>
28
-        <h2>Access</h2>
29
-        <span aria-disabled="true">{{ octicon "gear" }} Integrations</span>
30
-        <span aria-disabled="true">{{ octicon "history" }} Audit log</span>
31
-      </nav>
32
-    </aside>
13
+    {{ template "org-settings-nav" . }}
3314
 
3415
     <div class="shithub-org-settings-main">
3516
       {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}