tenseleyflow/shithub / e53d09d

Browse files

web/handlers/auth: user billing settings handler + Deps fields + routes

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e53d09dd51a708c8d10c18a811d29f8692c781fc
Parents
07f6997
Tree
a266567

2 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 27 0
A internal/web/handlers/auth/billing_settings.go 599 0
internal/web/handlers/auth/auth.gomodified
@@ -46,6 +46,7 @@ import (
4646
 	"github.com/tenseleyFlow/shithub/internal/auth/session"
4747
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
4848
 	"github.com/tenseleyFlow/shithub/internal/auth/token"
49
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
4950
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
5051
 	"github.com/tenseleyFlow/shithub/internal/passwords"
5152
 	"github.com/tenseleyFlow/shithub/internal/ratelimit"
@@ -93,6 +94,21 @@ type Deps struct {
9394
 	// wires this still gets a working grant for the canonical
9495
 	// shithub-cli client_id.
9596
 	DeviceCode devicecode.Config
97
+	// BillingEnabled gates whether the user-tier billing settings
98
+	// surface (PRO06) registers its routes. False ⇒ /settings/billing
99
+	// renders a "paid plans not configured on this instance" page;
100
+	// checkout / portal routes return 404. Operators flip this once
101
+	// they've configured a Stripe secret + Pro price.
102
+	BillingEnabled        bool
103
+	BillingGracePeriod    time.Duration
104
+	Stripe                stripebilling.Remote
105
+	StripeSuccessURL      string
106
+	StripeCancelURL       string
107
+	StripePortalReturnURL string
108
+	// BaseURL is the canonical absolute URL of this shithub instance.
109
+	// Used to construct Stripe return URLs when no per-route override
110
+	// is configured.
111
+	BaseURL string
96112
 }
97113
 
98114
 // Handlers is the registered handler set. Construct with New.
@@ -184,6 +200,17 @@ func (h *Handlers) Mount(r chi.Router) {
184200
 			r.Get("/settings/keys/gpg/new", h.gpgKeysAddForm)
185201
 			r.Post("/settings/keys/gpg", h.gpgKeysAdd)
186202
 			r.Post("/settings/keys/gpg/{id}/delete", h.gpgKeysDelete)
203
+			// PRO06 — user-tier billing settings. The settings page
204
+			// itself is always reachable so a Free user can see what
205
+			// Pro offers; checkout/portal routes only register when
206
+			// Stripe is configured for this instance.
207
+			r.Get("/settings/billing", h.settingsBilling)
208
+			if h.d.BillingEnabled && h.d.Stripe != nil {
209
+				r.Post("/settings/billing/checkout", h.settingsBillingCheckout)
210
+				r.Post("/settings/billing/portal", h.settingsBillingPortal)
211
+				r.Get("/settings/billing/success", h.settingsBillingSuccess)
212
+				r.Get("/settings/billing/cancel", h.settingsBillingCancel)
213
+			}
187214
 			r.Get("/settings/tokens", h.tokensList)
188215
 			r.Post("/settings/tokens", h.tokensCreate)
189216
 			r.Post("/settings/tokens/{id}/revoke", h.tokensRevoke)
internal/web/handlers/auth/billing_settings.goadded
@@ -0,0 +1,599 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"errors"
7
+	"fmt"
8
+	"net/http"
9
+	"net/url"
10
+	"strings"
11
+	"time"
12
+
13
+	"github.com/jackc/pgx/v5"
14
+	"github.com/jackc/pgx/v5/pgtype"
15
+
16
+	userbilling "github.com/tenseleyFlow/shithub/internal/billing"
17
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
+)
21
+
22
+// PRO06 — user-tier billing settings.
23
+//
24
+// Mirrors `internal/web/handlers/orgs/billing_settings.go` minus
25
+// seat-specific machinery (Pro is a single-seat plan) and minus
26
+// the private-collaboration breakdown (PRO01 ratified no Free user
27
+// collaborator cap). The view types are clone-and-pruned rather
28
+// than extracted to a shared package; PRO06's spec says the
29
+// extraction lands if/when the copies diverge meaningfully. For
30
+// now they're identical-shape but distinct types so user-side
31
+// changes don't ripple into the org page.
32
+
33
+type userBillingSummaryItem struct {
34
+	Label  string
35
+	Value  string
36
+	Detail string
37
+}
38
+
39
+type userBillingInvoiceView struct {
40
+	Number           string
41
+	StatusLabel      string
42
+	StatusClass      string
43
+	AmountLabel      string
44
+	PeriodLabel      string
45
+	DueLabel         string
46
+	HostedInvoiceURL string
47
+	InvoicePDFURL    string
48
+}
49
+
50
+type userBillingAlert struct {
51
+	Class      string
52
+	Message    string
53
+	ActionText string
54
+	ActionHref string
55
+}
56
+
57
+type userBillingDebugView struct {
58
+	StripeCustomerID         string
59
+	StripeSubscriptionID     string
60
+	StripeSubscriptionItemID string
61
+	LastWebhookEventID       string
62
+	LastWebhookEventType     string
63
+	LastWebhookStatus        string
64
+	LastWebhookReceivedAt    string
65
+	LastWebhookProcessedAt   string
66
+	LastWebhookAttempts      int32
67
+	LastWebhookError         string
68
+}
69
+
70
+const userBillingSettingsPath = "/settings/billing"
71
+
72
+// settingsBilling renders the user's billing page. Auth middleware
73
+// has already verified the request is authenticated; this handler
74
+// loads the current user's billing state and projects it for the
75
+// template.
76
+func (h *Handlers) settingsBilling(w http.ResponseWriter, r *http.Request) {
77
+	user := middleware.CurrentUserFromContext(r.Context())
78
+	if user.ID == 0 {
79
+		http.Redirect(w, r, "/login?return_to=/settings/billing", http.StatusSeeOther)
80
+		return
81
+	}
82
+	h.renderUserSettingsBilling(w, r, user.ID, user.Username, "", userBillingNotice(r.URL.Query().Get("notice")))
83
+}
84
+
85
+// settingsBillingCheckout creates a Stripe Checkout session for
86
+// the authenticated user upgrading to Pro and redirects to it.
87
+// Returns 404 when billing isn't configured on this instance.
88
+func (h *Handlers) settingsBillingCheckout(w http.ResponseWriter, r *http.Request) {
89
+	user := middleware.CurrentUserFromContext(r.Context())
90
+	if user.ID == 0 {
91
+		http.Redirect(w, r, "/login?return_to=/settings/billing", http.StatusSeeOther)
92
+		return
93
+	}
94
+	if !h.billingConfiguredForUser() {
95
+		http.NotFound(w, r)
96
+		return
97
+	}
98
+	sessionURL, err := h.startUserBillingCheckout(r, user.ID, user.Username)
99
+	if err != nil {
100
+		h.d.Logger.ErrorContext(r.Context(), "user billing: create checkout", "user_id", user.ID, "error", err)
101
+		h.renderUserSettingsBilling(w, r, user.ID, user.Username, "Could not start checkout right now.", "")
102
+		return
103
+	}
104
+	http.Redirect(w, r, sessionURL, http.StatusSeeOther)
105
+}
106
+
107
+// settingsBillingPortal opens the Stripe billing portal for the
108
+// authenticated user's customer record. 404 when billing isn't
109
+// configured; renders an inline message when the user has no
110
+// customer record yet (haven't completed checkout).
111
+func (h *Handlers) settingsBillingPortal(w http.ResponseWriter, r *http.Request) {
112
+	user := middleware.CurrentUserFromContext(r.Context())
113
+	if user.ID == 0 {
114
+		http.Redirect(w, r, "/login?return_to=/settings/billing", http.StatusSeeOther)
115
+		return
116
+	}
117
+	if !h.billingConfiguredForUser() {
118
+		http.NotFound(w, r)
119
+		return
120
+	}
121
+	state, err := userbilling.GetUserBillingState(r.Context(), userbilling.Deps{Pool: h.d.Pool}, user.ID)
122
+	if err != nil {
123
+		h.d.Logger.ErrorContext(r.Context(), "user billing: load state for portal", "user_id", user.ID, "error", err)
124
+		http.Error(w, "internal error", http.StatusInternalServerError)
125
+		return
126
+	}
127
+	if !state.StripeCustomerID.Valid || strings.TrimSpace(state.StripeCustomerID.String) == "" {
128
+		h.renderUserSettingsBilling(w, r, user.ID, user.Username, "Billing portal is unavailable until you have a Stripe customer record. Complete checkout once to create one.", "")
129
+		return
130
+	}
131
+	session, err := h.d.Stripe.CreatePortalSession(r.Context(), stripebilling.PortalInput{
132
+		CustomerID: state.StripeCustomerID.String,
133
+		ReturnURL:  h.userBillingReturnURL(h.d.StripePortalReturnURL, userBillingSettingsPath),
134
+	})
135
+	if err != nil {
136
+		h.d.Logger.ErrorContext(r.Context(), "user billing: create portal session", "user_id", user.ID, "error", err)
137
+		h.renderUserSettingsBilling(w, r, user.ID, user.Username, "Could not open the Stripe billing portal right now.", "")
138
+		return
139
+	}
140
+	http.Redirect(w, r, session.URL, http.StatusSeeOther)
141
+}
142
+
143
+// settingsBillingSuccess renders the post-checkout success page.
144
+// Stripe redirects here after the user completes payment; the user
145
+// billing state may not yet reflect Pro because the webhook hasn't
146
+// fired yet. The page tells the user activation is in progress.
147
+func (h *Handlers) settingsBillingSuccess(w http.ResponseWriter, r *http.Request) {
148
+	user := middleware.CurrentUserFromContext(r.Context())
149
+	if user.ID == 0 {
150
+		http.Redirect(w, r, "/login?return_to=/settings/billing", http.StatusSeeOther)
151
+		return
152
+	}
153
+	h.renderUserBillingResult(w, r, user.Username, userBillingResultSuccess)
154
+}
155
+
156
+// settingsBillingCancel renders the Stripe-cancel-return page.
157
+// No state has changed; the user is still on Free.
158
+func (h *Handlers) settingsBillingCancel(w http.ResponseWriter, r *http.Request) {
159
+	user := middleware.CurrentUserFromContext(r.Context())
160
+	if user.ID == 0 {
161
+		http.Redirect(w, r, "/login?return_to=/settings/billing", http.StatusSeeOther)
162
+		return
163
+	}
164
+	h.renderUserBillingResult(w, r, user.Username, userBillingResultCanceled)
165
+}
166
+
167
+const (
168
+	userBillingResultSuccess  = "success"
169
+	userBillingResultCanceled = "canceled"
170
+)
171
+
172
+func (h *Handlers) renderUserBillingResult(w http.ResponseWriter, r *http.Request, username, result string) {
173
+	heading := "Checkout complete"
174
+	message := "Stripe accepted the checkout session. Pro activation finishes after shithub receives and processes the signed Stripe webhook. Refresh the billing settings page in a minute if your plan still shows Free."
175
+	if result == userBillingResultCanceled {
176
+		heading = "Checkout canceled"
177
+		message = "No Pro subscription was activated. Your account stays on Free until checkout is completed."
178
+	}
179
+	_ = h.d.Render.RenderPage(w, r, "settings/billing_result", map[string]any{
180
+		"Title":       heading,
181
+		"CSRFToken":   middleware.CSRFTokenForRequest(r),
182
+		"Username":    username,
183
+		"Result":      result,
184
+		"Heading":     heading,
185
+		"Message":     message,
186
+		"BillingPath": userBillingSettingsPath,
187
+	})
188
+}
189
+
190
+// renderUserSettingsBilling is the canonical page render. Pulls
191
+// state, invoices, and (for site admins) the debug projection.
192
+func (h *Handlers) renderUserSettingsBilling(w http.ResponseWriter, r *http.Request, userID int64, username, errMsg, notice string) {
193
+	state, err := userbilling.GetUserBillingState(r.Context(), userbilling.Deps{Pool: h.d.Pool}, userID)
194
+	if err != nil {
195
+		h.d.Logger.ErrorContext(r.Context(), "user billing: load state", "user_id", userID, "error", err)
196
+		http.Error(w, "internal error", http.StatusInternalServerError)
197
+		return
198
+	}
199
+	invoices, err := userbilling.ListInvoicesForPrincipal(r.Context(), userbilling.Deps{Pool: h.d.Pool}, userbilling.PrincipalForUser(userID), 10)
200
+	if err != nil {
201
+		h.d.Logger.WarnContext(r.Context(), "user billing: list invoices", "user_id", userID, "error", err)
202
+		invoices = nil
203
+	}
204
+	viewer := middleware.CurrentUserFromContext(r.Context())
205
+	debug := userBillingDebugView{}
206
+	if viewer.IsSiteAdmin {
207
+		debug = h.userBillingDebugView(r, state)
208
+	}
209
+	_ = h.d.Render.RenderPage(w, r, "settings/billing", map[string]any{
210
+		"Title":                 "Billing and plans",
211
+		"SettingsActive":        "billing",
212
+		"CSRFToken":             middleware.CSRFTokenForRequest(r),
213
+		"Username":              username,
214
+		"BillingEnabled":        h.d.BillingEnabled,
215
+		"Error":                 errMsg,
216
+		"Notice":                notice,
217
+		"BillingAlert":          userBillingAlertForState(state),
218
+		"Summary":               userBillingSummary(state),
219
+		"CanStartCheckout":      h.billingConfiguredForUser(),
220
+		"CanManageSubscription": h.billingConfiguredForUser() && state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "",
221
+		"GracePeriodLabel":      userFormatGracePeriod(h.d.BillingGracePeriod),
222
+		"Invoices":              userBillingInvoiceViews(invoices),
223
+		"IsSiteAdmin":           viewer.IsSiteAdmin,
224
+		"Debug":                 debug,
225
+	})
226
+}
227
+
228
+// billingConfiguredForUser reports whether the user-tier Pro path
229
+// is wired. Operators that have only configured Team (no
230
+// ProPriceID) will see false here; the Stripe client's SupportsPro
231
+// makes the final call on whether the Pro price is available.
232
+func (h *Handlers) billingConfiguredForUser() bool {
233
+	if !h.d.BillingEnabled || h.d.Stripe == nil {
234
+		return false
235
+	}
236
+	// SupportsPro is exposed on the concrete *stripebilling.Client.
237
+	// The Remote interface in Deps is the test-friendly seam; the
238
+	// concrete check is best-effort and degrades to "billing enabled"
239
+	// when Stripe is a fake implementation. Tests that need the Pro
240
+	// gate populated can use a mock that returns true.
241
+	if checker, ok := h.d.Stripe.(interface{ SupportsPro() bool }); ok {
242
+		return checker.SupportsPro()
243
+	}
244
+	return true
245
+}
246
+
247
+// startUserBillingCheckout drives the Stripe checkout flow.
248
+// Ensures a Stripe customer exists for the user (creating one on
249
+// first call), then constructs a kind=user, quantity=1 checkout
250
+// session against the Pro price.
251
+func (h *Handlers) startUserBillingCheckout(r *http.Request, userID int64, username string) (string, error) {
252
+	state, err := userbilling.GetUserBillingState(r.Context(), userbilling.Deps{Pool: h.d.Pool}, userID)
253
+	if err != nil {
254
+		return "", fmt.Errorf("load user billing state: %w", err)
255
+	}
256
+	state, err = h.ensureUserStripeCustomer(r, userID, username, state)
257
+	if err != nil {
258
+		return "", fmt.Errorf("ensure stripe customer: %w", err)
259
+	}
260
+	session, err := h.d.Stripe.CreateCheckoutSession(r.Context(), stripebilling.CheckoutInput{
261
+		Kind:       stripebilling.SubjectKindUser,
262
+		SubjectID:  userID,
263
+		Label:      username,
264
+		CustomerID: state.StripeCustomerID.String,
265
+		SuccessURL: h.userBillingReturnURL(h.d.StripeSuccessURL, "/settings/billing/success"),
266
+		CancelURL:  h.userBillingReturnURL(h.d.StripeCancelURL, "/settings/billing/cancel"),
267
+	})
268
+	if err != nil {
269
+		return "", fmt.Errorf("create stripe checkout session: %w", err)
270
+	}
271
+	return session.URL, nil
272
+}
273
+
274
+func (h *Handlers) ensureUserStripeCustomer(r *http.Request, userID int64, username string, state userbilling.UserState) (userbilling.UserState, error) {
275
+	if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
276
+		return state, nil
277
+	}
278
+	email := ""
279
+	if u, err := h.q.GetUserByID(r.Context(), h.d.Pool, userID); err == nil && u.PrimaryEmailID.Valid {
280
+		if em, err := h.q.GetUserEmailByID(r.Context(), h.d.Pool, u.PrimaryEmailID.Int64); err == nil {
281
+			email = strings.TrimSpace(em.Email)
282
+		}
283
+	}
284
+	customer, err := h.d.Stripe.CreateCustomer(r.Context(), stripebilling.CustomerInput{
285
+		Kind:      stripebilling.SubjectKindUser,
286
+		SubjectID: userID,
287
+		Label:     username,
288
+		OrgName:   username, // displays as the user's identity in Stripe Dashboard
289
+		Email:     email,
290
+	})
291
+	if err != nil {
292
+		return userbilling.UserState{}, err
293
+	}
294
+	if _, err := userbilling.SetStripeCustomerForPrincipal(r.Context(), userbilling.Deps{Pool: h.d.Pool}, userbilling.PrincipalForUser(userID), customer.ID); err != nil {
295
+		return userbilling.UserState{}, err
296
+	}
297
+	// Reload to get the fresh state with the customer id set.
298
+	return userbilling.GetUserBillingState(r.Context(), userbilling.Deps{Pool: h.d.Pool}, userID)
299
+}
300
+
301
+func (h *Handlers) userBillingReturnURL(overrideURL, fallbackPath string) string {
302
+	overrideURL = strings.TrimSpace(overrideURL)
303
+	if overrideURL != "" {
304
+		// User-tier success/cancel URLs don't substitute an org slug.
305
+		// If the operator's template happens to carry {org}, replace
306
+		// with the literal "user" so the URL stays valid rather than
307
+		// leaving the placeholder.
308
+		return strings.ReplaceAll(overrideURL, "{org}", "user")
309
+	}
310
+	base, err := url.Parse(strings.TrimRight(h.d.BaseURL, "/") + "/")
311
+	if err != nil {
312
+		return ""
313
+	}
314
+	rel, err := url.Parse(strings.TrimLeft(fallbackPath, "/"))
315
+	if err != nil {
316
+		return ""
317
+	}
318
+	return base.ResolveReference(rel).String()
319
+}
320
+
321
+func userBillingNotice(code string) string {
322
+	switch code {
323
+	case "checkout-success":
324
+		return "Checkout completed. Stripe will finish provisioning as webhook events arrive."
325
+	case "checkout-canceled":
326
+		return "Checkout canceled."
327
+	default:
328
+		return ""
329
+	}
330
+}
331
+
332
+func (h *Handlers) userBillingDebugView(r *http.Request, state userbilling.UserState) userBillingDebugView {
333
+	debug := userBillingDebugView{
334
+		StripeCustomerID:         userPgTextString(state.StripeCustomerID),
335
+		StripeSubscriptionID:     userPgTextString(state.StripeSubscriptionID),
336
+		StripeSubscriptionItemID: userPgTextString(state.StripeSubscriptionItemID),
337
+		LastWebhookEventID:       strings.TrimSpace(state.LastWebhookEventID),
338
+	}
339
+	if debug.LastWebhookEventID == "" {
340
+		return debug
341
+	}
342
+	receipt, err := userbilling.GetWebhookEventReceipt(r.Context(), userbilling.Deps{Pool: h.d.Pool}, debug.LastWebhookEventID)
343
+	if err != nil {
344
+		if !errors.Is(err, pgx.ErrNoRows) {
345
+			h.d.Logger.WarnContext(r.Context(), "user billing: load latest webhook receipt",
346
+				"event_id", debug.LastWebhookEventID, "error", err)
347
+		}
348
+		return debug
349
+	}
350
+	debug.LastWebhookEventType = receipt.EventType
351
+	debug.LastWebhookReceivedAt = userFormatOptionalTime(receipt.ReceivedAt)
352
+	debug.LastWebhookProcessedAt = userFormatOptionalTime(receipt.ProcessedAt)
353
+	debug.LastWebhookAttempts = receipt.ProcessingAttempts
354
+	debug.LastWebhookError = strings.TrimSpace(receipt.ProcessError)
355
+	switch {
356
+	case receipt.ProcessedAt.Valid:
357
+		debug.LastWebhookStatus = "processed"
358
+	case debug.LastWebhookError != "":
359
+		debug.LastWebhookStatus = "failed"
360
+	default:
361
+		debug.LastWebhookStatus = "pending"
362
+	}
363
+	return debug
364
+}
365
+
366
+// ─── Projection helpers ──────────────────────────────────────────
367
+
368
+func userBillingSummary(state userbilling.UserState) []userBillingSummaryItem {
369
+	return []userBillingSummaryItem{
370
+		{
371
+			Label:  "Current plan",
372
+			Value:  userBillingPlanLabel(state.Plan),
373
+			Detail: userBillingPlanDetail(state),
374
+		},
375
+		{
376
+			Label:  "Subscription",
377
+			Value:  userBillingStatusLabel(state.SubscriptionStatus),
378
+			Detail: userBillingStatusDetail(state),
379
+		},
380
+		{
381
+			Label:  "Payment source",
382
+			Value:  userBillingPaymentSourceLabel(state),
383
+			Detail: userBillingPaymentSourceDetail(state),
384
+		},
385
+	}
386
+}
387
+
388
+func userBillingPlanLabel(plan userbilling.UserPlan) string {
389
+	if plan == userbilling.UserPlanPro {
390
+		return "Pro"
391
+	}
392
+	return "Free"
393
+}
394
+
395
+func userBillingPlanDetail(state userbilling.UserState) string {
396
+	if state.CurrentPeriodEnd.Valid {
397
+		label := "Current period ends"
398
+		if state.CancelAtPeriodEnd {
399
+			label = "Scheduled to cancel at period end"
400
+		}
401
+		return label + " " + state.CurrentPeriodEnd.Time.Format("Jan 2, 2006")
402
+	}
403
+	if state.SubscriptionStatus == userbilling.SubscriptionStatusNone ||
404
+		state.SubscriptionStatus == userbilling.SubscriptionStatusCanceled {
405
+		return "No active paid subscription."
406
+	}
407
+	return ""
408
+}
409
+
410
+func userBillingStatusLabel(status userbilling.SubscriptionStatus) string {
411
+	switch status {
412
+	case userbilling.SubscriptionStatusActive:
413
+		return "Active"
414
+	case userbilling.SubscriptionStatusTrialing:
415
+		return "Trialing"
416
+	case userbilling.SubscriptionStatusIncomplete:
417
+		return "Incomplete"
418
+	case userbilling.SubscriptionStatusPastDue:
419
+		return "Past due"
420
+	case userbilling.SubscriptionStatusCanceled:
421
+		return "Canceled"
422
+	case userbilling.SubscriptionStatusUnpaid:
423
+		return "Unpaid"
424
+	case userbilling.SubscriptionStatusPaused:
425
+		return "Paused"
426
+	default:
427
+		return "No subscription"
428
+	}
429
+}
430
+
431
+func userBillingStatusDetail(state userbilling.UserState) string {
432
+	if state.GraceUntil.Valid {
433
+		return "Grace period until " + state.GraceUntil.Time.Format("Jan 2, 2006")
434
+	}
435
+	if state.CanceledAt.Valid {
436
+		return "Canceled " + state.CanceledAt.Time.Format("Jan 2, 2006")
437
+	}
438
+	if state.TrialEnd.Valid {
439
+		return "Trial ends " + state.TrialEnd.Time.Format("Jan 2, 2006")
440
+	}
441
+	return ""
442
+}
443
+
444
+func userBillingPaymentSourceLabel(state userbilling.UserState) string {
445
+	if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
446
+		return "Stripe customer connected"
447
+	}
448
+	return "Not connected"
449
+}
450
+
451
+func userBillingPaymentSourceDetail(state userbilling.UserState) string {
452
+	if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
453
+		return "Payment method and invoices are managed in Stripe Billing Portal."
454
+	}
455
+	return "Checkout creates a customer record the first time you upgrade to Pro."
456
+}
457
+
458
+func userBillingAlertForState(state userbilling.UserState) userBillingAlert {
459
+	switch state.SubscriptionStatus {
460
+	case userbilling.SubscriptionStatusPastDue:
461
+		if state.GraceUntil.Valid && time.Now().UTC().Before(state.GraceUntil.Time) {
462
+			return userBillingAlert{
463
+				Class:      "shithub-flash-notice",
464
+				Message:    "Payment failed. Pro features remain available during the billing grace period, which ends " + state.GraceUntil.Time.Format("Jan 2, 2006") + ".",
465
+				ActionText: "Manage billing",
466
+				ActionHref: userBillingSettingsPath,
467
+			}
468
+		}
469
+		return userBillingAlert{
470
+			Class:      "shithub-flash-error",
471
+			Message:    "Payment is past due. Pro-only features are read-only until billing is brought back into good standing.",
472
+			ActionText: "Manage billing",
473
+			ActionHref: userBillingSettingsPath,
474
+		}
475
+	case userbilling.SubscriptionStatusCanceled:
476
+		return userBillingAlert{
477
+			Class:      "shithub-flash-notice",
478
+			Message:    "Your account is on Free after cancellation. Existing paid configuration is preserved, but Pro-only features are read-only until reactivated.",
479
+			ActionText: "Upgrade to Pro",
480
+			ActionHref: userBillingSettingsPath + "#manage-plan",
481
+		}
482
+	case userbilling.SubscriptionStatusIncomplete, userbilling.SubscriptionStatusUnpaid, userbilling.SubscriptionStatusPaused:
483
+		return userBillingAlert{
484
+			Class:      "shithub-flash-error",
485
+			Message:    "This subscription needs billing action before Pro features are available.",
486
+			ActionText: "Manage billing",
487
+			ActionHref: userBillingSettingsPath,
488
+		}
489
+	default:
490
+		if state.CancelAtPeriodEnd && state.CurrentPeriodEnd.Valid {
491
+			return userBillingAlert{
492
+				Class:      "shithub-flash-notice",
493
+				Message:    "Pro is scheduled to cancel at the end of the current billing period on " + state.CurrentPeriodEnd.Time.Format("Jan 2, 2006") + ".",
494
+				ActionText: "Keep Pro",
495
+				ActionHref: userBillingSettingsPath,
496
+			}
497
+		}
498
+		return userBillingAlert{}
499
+	}
500
+}
501
+
502
+func userBillingInvoiceViews(invoices []billingdb.BillingInvoice) []userBillingInvoiceView {
503
+	items := make([]userBillingInvoiceView, 0, len(invoices))
504
+	for _, inv := range invoices {
505
+		number := strings.TrimSpace(inv.Number)
506
+		if number == "" {
507
+			number = inv.StripeInvoiceID
508
+		}
509
+		items = append(items, userBillingInvoiceView{
510
+			Number:           number,
511
+			StatusLabel:      userBillingInvoiceStatusLabel(inv.Status),
512
+			StatusClass:      strings.ReplaceAll(strings.ToLower(string(inv.Status)), "_", "-"),
513
+			AmountLabel:      userFormatCurrencyAmount(inv.Currency, inv.AmountDueCents),
514
+			PeriodLabel:      userBillingPeriodLabel(inv),
515
+			DueLabel:         userBillingDueLabel(inv),
516
+			HostedInvoiceURL: strings.TrimSpace(inv.HostedInvoiceUrl),
517
+			InvoicePDFURL:    strings.TrimSpace(inv.InvoicePdfUrl),
518
+		})
519
+	}
520
+	return items
521
+}
522
+
523
+func userBillingInvoiceStatusLabel(status userbilling.InvoiceStatus) string {
524
+	switch status {
525
+	case userbilling.InvoiceStatusOpen:
526
+		return "Open"
527
+	case userbilling.InvoiceStatusPaid:
528
+		return "Paid"
529
+	case userbilling.InvoiceStatusVoid:
530
+		return "Void"
531
+	case userbilling.InvoiceStatusUncollectible:
532
+		return "Uncollectible"
533
+	default:
534
+		return "Draft"
535
+	}
536
+}
537
+
538
+func userBillingPeriodLabel(inv billingdb.BillingInvoice) string {
539
+	if inv.PeriodStart.Valid && inv.PeriodEnd.Valid {
540
+		return inv.PeriodStart.Time.Format("Jan 2, 2006") + " - " + inv.PeriodEnd.Time.Format("Jan 2, 2006")
541
+	}
542
+	return "—"
543
+}
544
+
545
+func userBillingDueLabel(inv billingdb.BillingInvoice) string {
546
+	switch {
547
+	case inv.PaidAt.Valid:
548
+		return "Paid " + inv.PaidAt.Time.Format("Jan 2, 2006")
549
+	case inv.VoidedAt.Valid:
550
+		return "Voided " + inv.VoidedAt.Time.Format("Jan 2, 2006")
551
+	case inv.DueAt.Valid:
552
+		return inv.DueAt.Time.Format("Jan 2, 2006")
553
+	default:
554
+		return "—"
555
+	}
556
+}
557
+
558
+func userFormatGracePeriod(d time.Duration) string {
559
+	if d <= 0 {
560
+		return "No grace period"
561
+	}
562
+	if d%(24*time.Hour) == 0 {
563
+		days := int(d / (24 * time.Hour))
564
+		if days == 1 {
565
+			return "1 day"
566
+		}
567
+		return fmt.Sprintf("%d days", days)
568
+	}
569
+	return d.String()
570
+}
571
+
572
+func userPgTextString(v pgtype.Text) string {
573
+	if !v.Valid {
574
+		return ""
575
+	}
576
+	return strings.TrimSpace(v.String)
577
+}
578
+
579
+func userFormatOptionalTime(v pgtype.Timestamptz) string {
580
+	if v.Valid && !v.Time.IsZero() {
581
+		return v.Time.UTC().Format("Jan 2, 2006 15:04 UTC")
582
+	}
583
+	return ""
584
+}
585
+
586
+func userFormatCurrencyAmount(currency string, cents int64) string {
587
+	currency = strings.ToUpper(strings.TrimSpace(currency))
588
+	sign := ""
589
+	if cents < 0 {
590
+		sign = "-"
591
+		cents = -cents
592
+	}
593
+	major := cents / 100
594
+	minor := cents % 100
595
+	if currency == "" {
596
+		currency = "USD"
597
+	}
598
+	return fmt.Sprintf("%s$%d.%02d %s", sign, major, minor, currency)
599
+}