tenseleyflow/shithub / 77b25b6

Browse files

web/handlers/auth: PRO06 user-tier billing settings tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
77b25b638726071c45f568f3de365053d52c5ffa
Parents
5ec555b
Tree
9d77c81

2 changed files

StatusFile+-
M internal/web/handlers/auth/auth_test.go 43 27
A internal/web/handlers/auth/billing_settings_test.go 392 0
internal/web/handlers/auth/auth_test.gomodified
@@ -29,6 +29,7 @@ import (
29
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
29
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
30
 	"github.com/tenseleyFlow/shithub/internal/auth/session"
30
 	"github.com/tenseleyFlow/shithub/internal/auth/session"
31
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
31
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
32
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
32
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
33
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
33
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
34
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
34
 	authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth"
35
 	authh "github.com/tenseleyFlow/shithub/internal/web/handlers/auth"
@@ -76,6 +77,10 @@ func newTestServer(t *testing.T, requireVerify bool) (*httptest.Server, *capture
76
 type authTestOptions struct {
77
 type authTestOptions struct {
77
 	RequireVerify     bool
78
 	RequireVerify     bool
78
 	OrgBillingEnabled bool
79
 	OrgBillingEnabled bool
80
+	// BillingEnabled toggles the user-tier billing routes (PRO06).
81
+	// When true, Stripe (if non-nil) provides the Remote backend.
82
+	BillingEnabled bool
83
+	Stripe         stripebilling.Remote
79
 }
84
 }
80
 
85
 
81
 // newTestServerWithPool is identical to newTestServer but also exposes
86
 // newTestServerWithPool is identical to newTestServer but also exposes
@@ -136,6 +141,13 @@ func newTestServerWithPoolOptions(t *testing.T, opts authTestOptions) (*httptest
136
 		SecretBox:                box,
141
 		SecretBox:                box,
137
 		ObjectStore:              storage.NewMemoryStore(),
142
 		ObjectStore:              storage.NewMemoryStore(),
138
 		OrgBillingEnabled:        opts.OrgBillingEnabled,
143
 		OrgBillingEnabled:        opts.OrgBillingEnabled,
144
+		BillingEnabled:           opts.BillingEnabled,
145
+		BillingGracePeriod:       14 * 24 * time.Hour,
146
+		Stripe:                   opts.Stripe,
147
+		StripeSuccessURL:         "http://test.invalid/settings/billing/success",
148
+		StripeCancelURL:          "http://test.invalid/settings/billing/cancel",
149
+		StripePortalReturnURL:    "http://test.invalid/settings/billing",
150
+		BaseURL:                  "http://test.invalid",
139
 	})
151
 	})
140
 	if err != nil {
152
 	if err != nil {
141
 		t.Fatalf("authh.New: %v", err)
153
 		t.Fatalf("authh.New: %v", err)
@@ -214,35 +226,39 @@ func authTemplatesFS() fs.FS {
214
 	organizationsTpl := `{{ define "page" }}<h1>Organizations</h1>USER={{.Username}};ORGS={{ range .Organizations }}{{.Slug}}:{{.RoleLabel}}:manage={{.CanManage}}:compare={{.CompareHref}};{{ end }}{{ end }}`
226
 	organizationsTpl := `{{ define "page" }}<h1>Organizations</h1>USER={{.Username}};ORGS={{ range .Organizations }}{{.Slug}}:{{.RoleLabel}}:manage={{.CanManage}}:compare={{.CompareHref}};{{ end }}{{ end }}`
215
 	sessTpl := `{{ define "page" }}<h1>Sessions</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/sessions/logout-everywhere" method=POST><input name=csrf_token value="{{.CSRFToken}}">UA={{.UserAgent}};</form>{{ end }}`
227
 	sessTpl := `{{ define "page" }}<h1>Sessions</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/sessions/logout-everywhere" method=POST><input name=csrf_token value="{{.CSRFToken}}">UA={{.UserAgent}};</form>{{ end }}`
216
 	dangerTpl := `{{ define "page" }}<h1>Delete</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<form action="/settings/danger" method=POST><input name=csrf_token value="{{.CSRFToken}}">USER={{.Username}};GRACE={{.GraceWindowDays}};</form>{{ end }}`
228
 	dangerTpl := `{{ define "page" }}<h1>Delete</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<form action="/settings/danger" method=POST><input name=csrf_token value="{{.CSRFToken}}">USER={{.Username}};GRACE={{.GraceWindowDays}};</form>{{ end }}`
229
+	billingTpl := `{{ define "page" }}<h1>Billing</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Notice }}<p class=notice>{{.}}</p>{{ end }}{{ with .BillingAlert }}{{ if .Message }}ALERT={{.Message}}{{ end }}{{ end }}<form action="/settings/billing/checkout" method=POST><input name=csrf_token value="{{.CSRFToken}}">CHECKOUT={{ .CanStartCheckout }};MANAGE={{ .CanManageSubscription }};</form><form action="/settings/billing/portal" method=POST><input name=csrf_token value="{{.CSRFToken}}"></form>{{ range .Summary }}SUMMARY={{.Label}}|{{.Value}};{{ end }}{{ if .IsSiteAdmin }}DEBUG={{ .Debug.StripeCustomerID }}|{{ .Debug.StripeSubscriptionID }};{{ end }}{{ range .Invoices }}INVOICE={{.Number}};{{ end }}{{ end }}`
230
+	billingResultTpl := `{{ define "page" }}RESULT={{.Result}};HEADING={{.Heading}};USER={{.Username}};BILLING={{.BillingPath}};<input name=csrf_token value="{{.CSRFToken}}">{{ end }}`
217
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
231
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
218
 	return fstest.MapFS{
232
 	return fstest.MapFS{
219
-		"_layout.html":                {Data: []byte(layout)},
233
+		"_layout.html":                 {Data: []byte(layout)},
220
-		"hello.html":                  {Data: []byte(`{{ define "page" }}home{{ end }}`)},
234
+		"hello.html":                   {Data: []byte(`{{ define "page" }}home{{ end }}`)},
221
-		"auth/signup.html":            {Data: []byte(signup)},
235
+		"auth/signup.html":             {Data: []byte(signup)},
222
-		"auth/login.html":             {Data: []byte(login)},
236
+		"auth/login.html":              {Data: []byte(login)},
223
-		"auth/reset_request.html":     {Data: []byte(resetReq)},
237
+		"auth/reset_request.html":      {Data: []byte(resetReq)},
224
-		"auth/reset_confirm.html":     {Data: []byte(resetConf)},
238
+		"auth/reset_confirm.html":      {Data: []byte(resetConf)},
225
-		"auth/verify_resend.html":     {Data: []byte(verifyResend)},
239
+		"auth/verify_resend.html":      {Data: []byte(verifyResend)},
226
-		"auth/2fa_challenge.html":     {Data: []byte(tfaChallenge)},
240
+		"auth/2fa_challenge.html":      {Data: []byte(tfaChallenge)},
227
-		"settings/2fa_enable.html":    {Data: []byte(tfaEnable)},
241
+		"settings/2fa_enable.html":     {Data: []byte(tfaEnable)},
228
-		"settings/2fa_disable.html":   {Data: []byte(tfaDisable)},
242
+		"settings/2fa_disable.html":    {Data: []byte(tfaDisable)},
229
-		"settings/2fa_recovery.html":  {Data: []byte(tfaRecovery)},
243
+		"settings/2fa_recovery.html":   {Data: []byte(tfaRecovery)},
230
-		"settings/keys.html":          {Data: []byte(keysTpl)},
244
+		"settings/keys.html":           {Data: []byte(keysTpl)},
231
-		"settings/keys_gpg_add.html":  {Data: []byte(gpgAddTpl)},
245
+		"settings/keys_gpg_add.html":   {Data: []byte(gpgAddTpl)},
232
-		"settings/tokens.html":        {Data: []byte(tokensTpl)},
246
+		"settings/tokens.html":         {Data: []byte(tokensTpl)},
233
-		"settings/profile.html":       {Data: []byte(profileTpl)},
247
+		"settings/profile.html":        {Data: []byte(profileTpl)},
234
-		"settings/account.html":       {Data: []byte(accountTpl)},
248
+		"settings/account.html":        {Data: []byte(accountTpl)},
235
-		"settings/password.html":      {Data: []byte(pwTpl)},
249
+		"settings/password.html":       {Data: []byte(pwTpl)},
236
-		"settings/appearance.html":    {Data: []byte(apprTpl)},
250
+		"settings/appearance.html":     {Data: []byte(apprTpl)},
237
-		"settings/emails.html":        {Data: []byte(emailsTpl)},
251
+		"settings/emails.html":         {Data: []byte(emailsTpl)},
238
-		"settings/notifications.html": {Data: []byte(notifTpl)},
252
+		"settings/notifications.html":  {Data: []byte(notifTpl)},
239
-		"settings/organizations.html": {Data: []byte(organizationsTpl)},
253
+		"settings/organizations.html":  {Data: []byte(organizationsTpl)},
240
-		"settings/sessions.html":      {Data: []byte(sessTpl)},
254
+		"settings/sessions.html":       {Data: []byte(sessTpl)},
241
-		"settings/danger.html":        {Data: []byte(dangerTpl)},
255
+		"settings/danger.html":         {Data: []byte(dangerTpl)},
242
-		"errors/404.html":             {Data: []byte(errorPage)},
256
+		"settings/billing.html":        {Data: []byte(billingTpl)},
243
-		"errors/403.html":             {Data: []byte(errorPage)},
257
+		"settings/billing_result.html": {Data: []byte(billingResultTpl)},
244
-		"errors/429.html":             {Data: []byte(errorPage)},
258
+		"errors/404.html":              {Data: []byte(errorPage)},
245
-		"errors/500.html":             {Data: []byte(errorPage)},
259
+		"errors/403.html":              {Data: []byte(errorPage)},
260
+		"errors/429.html":              {Data: []byte(errorPage)},
261
+		"errors/500.html":              {Data: []byte(errorPage)},
246
 	}
262
 	}
247
 }
263
 }
248
 
264
 
internal/web/handlers/auth/billing_settings_test.goadded
@@ -0,0 +1,392 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"net/http"
9
+	"net/http/httptest"
10
+	"net/url"
11
+	"strings"
12
+	"testing"
13
+	"time"
14
+
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+	stripeapi "github.com/stripe/stripe-go/v85"
18
+
19
+	userbilling "github.com/tenseleyFlow/shithub/internal/billing"
20
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
21
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
22
+)
23
+
24
+func billingPgText(s string) pgtype.Text {
25
+	return pgtype.Text{String: s, Valid: true}
26
+}
27
+
28
+func billingPgTime(t time.Time) pgtype.Timestamptz {
29
+	return pgtype.Timestamptz{Time: t, Valid: true}
30
+}
31
+
32
+// PRO06 — user-tier billing settings tests.
33
+//
34
+// These tests share the same server scaffold as the rest of the auth
35
+// suite (newTestServerWithPoolOptions) but flip BillingEnabled and
36
+// hand in a fakeUserStripeRemote so checkout/portal routes register
37
+// and Stripe interactions are observable.
38
+
39
+type fakeUserStripeRemote struct {
40
+	createCustomerFn func(context.Context, stripebilling.CustomerInput) (stripebilling.Customer, error)
41
+	createCheckoutFn func(context.Context, stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error)
42
+	createPortalFn   func(context.Context, stripebilling.PortalInput) (stripebilling.PortalSession, error)
43
+	supportsPro      bool
44
+}
45
+
46
+func (f *fakeUserStripeRemote) CreateCustomer(ctx context.Context, in stripebilling.CustomerInput) (stripebilling.Customer, error) {
47
+	if f.createCustomerFn == nil {
48
+		return stripebilling.Customer{ID: "cus_default"}, nil
49
+	}
50
+	return f.createCustomerFn(ctx, in)
51
+}
52
+
53
+func (f *fakeUserStripeRemote) CreateCheckoutSession(ctx context.Context, in stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error) {
54
+	if f.createCheckoutFn == nil {
55
+		return stripebilling.CheckoutSession{}, nil
56
+	}
57
+	return f.createCheckoutFn(ctx, in)
58
+}
59
+
60
+func (f *fakeUserStripeRemote) CreatePortalSession(ctx context.Context, in stripebilling.PortalInput) (stripebilling.PortalSession, error) {
61
+	if f.createPortalFn == nil {
62
+		return stripebilling.PortalSession{}, nil
63
+	}
64
+	return f.createPortalFn(ctx, in)
65
+}
66
+
67
+func (f *fakeUserStripeRemote) UpdateSubscriptionItemQuantity(_ context.Context, _ stripebilling.SeatQuantityInput) error {
68
+	return nil
69
+}
70
+
71
+func (f *fakeUserStripeRemote) VerifyWebhook(_ []byte, _ string) (stripeapi.Event, error) {
72
+	return stripeapi.Event{}, nil
73
+}
74
+
75
+func (f *fakeUserStripeRemote) SupportsPro() bool { return f.supportsPro }
76
+
77
+// newBillingTestUser signs up + verifies + logs in a fresh user against
78
+// the supplied server. Returns the client (with session cookie) and the
79
+// user's id.
80
+func newBillingTestUser(t *testing.T, srv *httptest.Server, pool *pgxpool.Pool, captor *captureSender, username string) (*client, int64) {
81
+	t.Helper()
82
+	cli := newClient(t, srv)
83
+	mustSignup(t, cli, username, username+"@example.com", "correct horse battery staple")
84
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
85
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
86
+	csrf := cli.extractCSRF(t, "/login")
87
+	resp := cli.post(t, "/login", url.Values{
88
+		"csrf_token": {csrf},
89
+		"username":   {username},
90
+		"password":   {"correct horse battery staple"},
91
+	})
92
+	defer func() { _ = resp.Body.Close() }()
93
+	if resp.StatusCode != http.StatusSeeOther {
94
+		t.Fatalf("login: %d", resp.StatusCode)
95
+	}
96
+	var id int64
97
+	if err := pool.QueryRow(context.Background(),
98
+		`SELECT id FROM users WHERE username = $1`, username).Scan(&id); err != nil {
99
+		t.Fatalf("lookup user id: %v", err)
100
+	}
101
+	return cli, id
102
+}
103
+
104
+func TestUserBillingSettingsFreeUserShowsUpgradeCTA(t *testing.T) {
105
+	t.Parallel()
106
+	srv, pool, captor := newTestServerWithPoolOptions(t, authTestOptions{
107
+		BillingEnabled: true,
108
+		Stripe:         &fakeUserStripeRemote{supportsPro: true},
109
+	})
110
+	cli, _ := newBillingTestUser(t, srv, pool, captor, "freeuser")
111
+
112
+	resp := cli.get(t, "/settings/billing")
113
+	defer func() { _ = resp.Body.Close() }()
114
+	if resp.StatusCode != http.StatusOK {
115
+		t.Fatalf("status=%d", resp.StatusCode)
116
+	}
117
+	body, _ := io.ReadAll(resp.Body)
118
+	s := string(body)
119
+	if !strings.Contains(s, "SUMMARY=Current plan|Free;") {
120
+		t.Errorf("expected Free plan summary, got: %s", s)
121
+	}
122
+	if !strings.Contains(s, "CHECKOUT=true;") {
123
+		t.Errorf("expected CanStartCheckout=true for Free user: %s", s)
124
+	}
125
+	if !strings.Contains(s, "MANAGE=false;") {
126
+		t.Errorf("expected CanManageSubscription=false before customer exists: %s", s)
127
+	}
128
+}
129
+
130
+func TestUserBillingSettingsProUserShowsPlanCardAndPortal(t *testing.T) {
131
+	t.Parallel()
132
+	ctx := context.Background()
133
+	srv, pool, captor := newTestServerWithPoolOptions(t, authTestOptions{
134
+		BillingEnabled: true,
135
+		Stripe:         &fakeUserStripeRemote{supportsPro: true},
136
+	})
137
+	cli, userID := newBillingTestUser(t, srv, pool, captor, "prouser")
138
+	deps := userbilling.Deps{Pool: pool}
139
+	if _, err := userbilling.SetStripeCustomerForPrincipal(ctx, deps, userbilling.PrincipalForUser(userID), "cus_pro_test"); err != nil {
140
+		t.Fatalf("SetStripeCustomerForPrincipal: %v", err)
141
+	}
142
+	if _, err := billingdb.New().ApplyUserSubscriptionSnapshot(ctx, pool, billingdb.ApplyUserSubscriptionSnapshotParams{
143
+		UserID:               userID,
144
+		Plan:                 billingdb.UserPlanPro,
145
+		SubscriptionStatus:   billingdb.BillingSubscriptionStatusActive,
146
+		StripeSubscriptionID: billingPgText("sub_pro_test"),
147
+		CurrentPeriodStart:   billingPgTime(time.Now().UTC().Add(-time.Hour)),
148
+		CurrentPeriodEnd:     billingPgTime(time.Now().UTC().Add(30 * 24 * time.Hour)),
149
+		LastWebhookEventID:   "evt_pro_test",
150
+	}); err != nil {
151
+		t.Fatalf("ApplyUserSubscriptionSnapshot: %v", err)
152
+	}
153
+
154
+	resp := cli.get(t, "/settings/billing")
155
+	defer func() { _ = resp.Body.Close() }()
156
+	body, _ := io.ReadAll(resp.Body)
157
+	s := string(body)
158
+	if !strings.Contains(s, "SUMMARY=Current plan|Pro;") {
159
+		t.Errorf("expected Pro plan summary: %s", s)
160
+	}
161
+	if !strings.Contains(s, "MANAGE=true;") {
162
+		t.Errorf("Pro user with customer id should have manage=true: %s", s)
163
+	}
164
+	if strings.Contains(s, "cus_pro_test") {
165
+		t.Errorf("non-admin viewer should not see raw stripe customer id: %s", s)
166
+	}
167
+}
168
+
169
+func TestUserBillingSettingsCancelAtPeriodEndShowsAlert(t *testing.T) {
170
+	t.Parallel()
171
+	ctx := context.Background()
172
+	srv, pool, captor := newTestServerWithPoolOptions(t, authTestOptions{
173
+		BillingEnabled: true,
174
+		Stripe:         &fakeUserStripeRemote{supportsPro: true},
175
+	})
176
+	cli, userID := newBillingTestUser(t, srv, pool, captor, "scheduleuser")
177
+	deps := userbilling.Deps{Pool: pool}
178
+	if _, err := userbilling.SetStripeCustomerForPrincipal(ctx, deps, userbilling.PrincipalForUser(userID), "cus_sched"); err != nil {
179
+		t.Fatalf("SetStripeCustomerForPrincipal: %v", err)
180
+	}
181
+	if _, err := billingdb.New().ApplyUserSubscriptionSnapshot(ctx, pool, billingdb.ApplyUserSubscriptionSnapshotParams{
182
+		UserID:               userID,
183
+		Plan:                 billingdb.UserPlanPro,
184
+		SubscriptionStatus:   billingdb.BillingSubscriptionStatusActive,
185
+		StripeSubscriptionID: billingPgText("sub_sched"),
186
+		CurrentPeriodStart:   billingPgTime(time.Now().UTC().Add(-time.Hour)),
187
+		CurrentPeriodEnd:     billingPgTime(time.Now().UTC().Add(30 * 24 * time.Hour)),
188
+		CancelAtPeriodEnd:    true,
189
+		LastWebhookEventID:   "evt_sched",
190
+	}); err != nil {
191
+		t.Fatalf("ApplyUserSubscriptionSnapshot: %v", err)
192
+	}
193
+
194
+	resp := cli.get(t, "/settings/billing")
195
+	defer func() { _ = resp.Body.Close() }()
196
+	body, _ := io.ReadAll(resp.Body)
197
+	s := string(body)
198
+	if !strings.Contains(s, "ALERT=Pro is scheduled to cancel") {
199
+		t.Errorf("expected cancel-at-period-end alert: %s", s)
200
+	}
201
+}
202
+
203
+func TestUserBillingSettingsPastDueShowsAlert(t *testing.T) {
204
+	t.Parallel()
205
+	ctx := context.Background()
206
+	srv, pool, captor := newTestServerWithPoolOptions(t, authTestOptions{
207
+		BillingEnabled: true,
208
+		Stripe:         &fakeUserStripeRemote{supportsPro: true},
209
+	})
210
+	cli, userID := newBillingTestUser(t, srv, pool, captor, "pastdueuser")
211
+	deps := userbilling.Deps{Pool: pool}
212
+	if _, err := userbilling.SetStripeCustomerForPrincipal(ctx, deps, userbilling.PrincipalForUser(userID), "cus_pastdue"); err != nil {
213
+		t.Fatalf("SetStripeCustomerForPrincipal: %v", err)
214
+	}
215
+	if _, err := userbilling.MarkPastDueForPrincipal(ctx, deps, userbilling.PrincipalForUser(userID), time.Now().UTC().Add(7*24*time.Hour), "evt_pastdue"); err != nil {
216
+		t.Fatalf("MarkPastDueForPrincipal: %v", err)
217
+	}
218
+
219
+	resp := cli.get(t, "/settings/billing")
220
+	defer func() { _ = resp.Body.Close() }()
221
+	body, _ := io.ReadAll(resp.Body)
222
+	s := string(body)
223
+	if !strings.Contains(s, "ALERT=Payment failed.") {
224
+		t.Errorf("expected past-due alert: %s", s)
225
+	}
226
+}
227
+
228
+func TestUserBillingCheckoutRedirectsToStripeAndPersistsCustomer(t *testing.T) {
229
+	t.Parallel()
230
+	ctx := context.Background()
231
+	fake := &fakeUserStripeRemote{
232
+		supportsPro: true,
233
+		createCustomerFn: func(_ context.Context, in stripebilling.CustomerInput) (stripebilling.Customer, error) {
234
+			if in.Kind != stripebilling.SubjectKindUser {
235
+				t.Errorf("checkout customer kind = %q, want user", in.Kind)
236
+			}
237
+			return stripebilling.Customer{ID: "cus_checkout"}, nil
238
+		},
239
+		createCheckoutFn: func(_ context.Context, in stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error) {
240
+			if in.Kind != stripebilling.SubjectKindUser {
241
+				t.Errorf("checkout kind = %q, want user", in.Kind)
242
+			}
243
+			if in.CustomerID != "cus_checkout" {
244
+				t.Errorf("checkout customer id = %q", in.CustomerID)
245
+			}
246
+			if !strings.Contains(in.SuccessURL, "/settings/billing/success") {
247
+				t.Errorf("checkout success URL = %q", in.SuccessURL)
248
+			}
249
+			if !strings.Contains(in.CancelURL, "/settings/billing/cancel") {
250
+				t.Errorf("checkout cancel URL = %q", in.CancelURL)
251
+			}
252
+			return stripebilling.CheckoutSession{ID: "cs_user", URL: "https://checkout.stripe.test/user"}, nil
253
+		},
254
+	}
255
+	srv, pool, captor := newTestServerWithPoolOptions(t, authTestOptions{
256
+		BillingEnabled: true,
257
+		Stripe:         fake,
258
+	})
259
+	cli, userID := newBillingTestUser(t, srv, pool, captor, "checkoutuser")
260
+	csrf := cli.extractCSRF(t, "/settings/billing")
261
+
262
+	resp := cli.post(t, "/settings/billing/checkout", url.Values{"csrf_token": {csrf}})
263
+	defer func() { _ = resp.Body.Close() }()
264
+	if resp.StatusCode != http.StatusSeeOther {
265
+		body, _ := io.ReadAll(resp.Body)
266
+		t.Fatalf("checkout status=%d body=%s", resp.StatusCode, body)
267
+	}
268
+	if got := resp.Header.Get("Location"); got != "https://checkout.stripe.test/user" {
269
+		t.Fatalf("checkout redirect=%q", got)
270
+	}
271
+	state, err := userbilling.GetUserBillingState(ctx, userbilling.Deps{Pool: pool}, userID)
272
+	if err != nil {
273
+		t.Fatalf("GetUserBillingState: %v", err)
274
+	}
275
+	if !state.StripeCustomerID.Valid || state.StripeCustomerID.String != "cus_checkout" {
276
+		t.Fatalf("customer not persisted: %+v", state.StripeCustomerID)
277
+	}
278
+}
279
+
280
+func TestUserBillingPortalRedirectsToStripe(t *testing.T) {
281
+	t.Parallel()
282
+	ctx := context.Background()
283
+	fake := &fakeUserStripeRemote{
284
+		supportsPro: true,
285
+		createPortalFn: func(_ context.Context, in stripebilling.PortalInput) (stripebilling.PortalSession, error) {
286
+			if in.CustomerID != "cus_portal_user" {
287
+				t.Errorf("portal customer id = %q", in.CustomerID)
288
+			}
289
+			if !strings.Contains(in.ReturnURL, "/settings/billing") {
290
+				t.Errorf("portal return URL = %q", in.ReturnURL)
291
+			}
292
+			return stripebilling.PortalSession{ID: "bps_user", URL: "https://billing.stripe.test/user"}, nil
293
+		},
294
+	}
295
+	srv, pool, captor := newTestServerWithPoolOptions(t, authTestOptions{
296
+		BillingEnabled: true,
297
+		Stripe:         fake,
298
+	})
299
+	cli, userID := newBillingTestUser(t, srv, pool, captor, "portaluser")
300
+	if _, err := userbilling.SetStripeCustomerForPrincipal(ctx, userbilling.Deps{Pool: pool}, userbilling.PrincipalForUser(userID), "cus_portal_user"); err != nil {
301
+		t.Fatalf("SetStripeCustomerForPrincipal: %v", err)
302
+	}
303
+	csrf := cli.extractCSRF(t, "/settings/billing")
304
+
305
+	resp := cli.post(t, "/settings/billing/portal", url.Values{"csrf_token": {csrf}})
306
+	defer func() { _ = resp.Body.Close() }()
307
+	if resp.StatusCode != http.StatusSeeOther {
308
+		body, _ := io.ReadAll(resp.Body)
309
+		t.Fatalf("portal status=%d body=%s", resp.StatusCode, body)
310
+	}
311
+	if got := resp.Header.Get("Location"); got != "https://billing.stripe.test/user" {
312
+		t.Fatalf("portal redirect=%q", got)
313
+	}
314
+}
315
+
316
+// When billing isn't configured on the instance, the page itself
317
+// renders (so users see "billing not configured"), but POST/checkout
318
+// returns 404 because the routes aren't registered.
319
+func TestUserBillingStripeDisabledHides404sCheckout(t *testing.T) {
320
+	t.Parallel()
321
+	srv, pool, captor := newTestServerWithPoolOptions(t, authTestOptions{
322
+		BillingEnabled: false,
323
+	})
324
+	cli, _ := newBillingTestUser(t, srv, pool, captor, "disableduser")
325
+
326
+	// The settings page itself stays reachable.
327
+	resp := cli.get(t, "/settings/billing")
328
+	defer func() { _ = resp.Body.Close() }()
329
+	if resp.StatusCode != http.StatusOK {
330
+		t.Fatalf("/settings/billing status=%d", resp.StatusCode)
331
+	}
332
+	body, _ := io.ReadAll(resp.Body)
333
+	if !strings.Contains(string(body), "CHECKOUT=false;") {
334
+		t.Errorf("disabled instance should not advertise checkout: %s", body)
335
+	}
336
+
337
+	// The checkout endpoint is not registered → 404.
338
+	csrf := cli.extractCSRF(t, "/settings/billing")
339
+	resp2 := cli.post(t, "/settings/billing/checkout", url.Values{"csrf_token": {csrf}})
340
+	defer func() { _ = resp2.Body.Close() }()
341
+	if resp2.StatusCode != http.StatusNotFound {
342
+		t.Fatalf("disabled checkout status=%d, want 404", resp2.StatusCode)
343
+	}
344
+}
345
+
346
+func TestUserBillingRequiresAuth(t *testing.T) {
347
+	t.Parallel()
348
+	srv, _, _ := newTestServerWithPoolOptions(t, authTestOptions{
349
+		BillingEnabled: true,
350
+		Stripe:         &fakeUserStripeRemote{supportsPro: true},
351
+	})
352
+	cli := newClient(t, srv)
353
+	resp := cli.get(t, "/settings/billing")
354
+	defer func() { _ = resp.Body.Close() }()
355
+	if resp.StatusCode != http.StatusSeeOther {
356
+		t.Fatalf("unauth status=%d, want 303 to /login", resp.StatusCode)
357
+	}
358
+	loc := resp.Header.Get("Location")
359
+	if !strings.HasPrefix(loc, "/login") {
360
+		t.Errorf("expected redirect to /login, got %q", loc)
361
+	}
362
+}
363
+
364
+func TestUserBillingResultPagesRender(t *testing.T) {
365
+	t.Parallel()
366
+	srv, pool, captor := newTestServerWithPoolOptions(t, authTestOptions{
367
+		BillingEnabled: true,
368
+		Stripe:         &fakeUserStripeRemote{supportsPro: true},
369
+	})
370
+	cli, _ := newBillingTestUser(t, srv, pool, captor, "resultuser")
371
+
372
+	for _, tc := range []struct {
373
+		path string
374
+		want string
375
+	}{
376
+		{path: "/settings/billing/success", want: "RESULT=success;HEADING=Checkout complete;"},
377
+		{path: "/settings/billing/cancel", want: "RESULT=canceled;HEADING=Checkout canceled;"},
378
+	} {
379
+		resp := cli.get(t, tc.path)
380
+		if resp.StatusCode != http.StatusOK {
381
+			body, _ := io.ReadAll(resp.Body)
382
+			_ = resp.Body.Close()
383
+			t.Errorf("%s status=%d body=%s", tc.path, resp.StatusCode, body)
384
+			continue
385
+		}
386
+		body, _ := io.ReadAll(resp.Body)
387
+		_ = resp.Body.Close()
388
+		if !strings.Contains(string(body), tc.want) {
389
+			t.Errorf("%s missing %q in: %s", tc.path, tc.want, body)
390
+		}
391
+	}
392
+}