tenseleyflow/shithub / 122ca19

Browse files

Add org billing checkout result pages

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
122ca19fe434b67ab6a3065f184bef84be9e9819
Parents
4a186b5
Tree
7e5468e

6 changed files

StatusFile+-
M docs/internal/billing.md 11 0
M docs/internal/orgs.md 4 1
M internal/web/handlers/orgs/billing_settings.go 26 2
M internal/web/handlers/orgs/billing_test.go 27 0
M internal/web/static/css/shithub.css 30 0
A internal/web/templates/orgs/billing_result.html 23 0
docs/internal/billing.mdmodified
@@ -169,6 +169,17 @@ PAYMENTS SP03 adds the first Stripe operator contract:
169
 The operator enablement flow is documented in
169
 The operator enablement flow is documented in
170
 [`runbooks/stripe-billing.md`](./runbooks/stripe-billing.md).
170
 [`runbooks/stripe-billing.md`](./runbooks/stripe-billing.md).
171
 
171
 
172
+PAYMENTS SP04 adds the self-serve onboarding flow:
173
+
174
+- `/organizations/plan` is the canonical plan picker.
175
+- Free setup creates the organization locally without Stripe.
176
+- Team setup creates the organization, creates or reuses the Stripe
177
+  customer, counts billable seats, and redirects directly to hosted
178
+  Stripe Checkout.
179
+- Checkout success and cancel returns render shithub pages. Success
180
+  tells the owner that activation waits for webhook processing; cancel
181
+  keeps the organization on Free and offers a retry path.
182
+
172
 ## Entitlement architecture
183
 ## Entitlement architecture
173
 
184
 
174
 Paid feature checks must live behind a central entitlement package, not
185
 Paid feature checks must live behind a central entitlement package, not
docs/internal/orgs.mdmodified
@@ -202,7 +202,10 @@ Billing is not fully configured, Team remains visible but disabled;
202
 site admins see operator setup copy. Choosing Team creates the
202
 site admins see operator setup copy. Choosing Team creates the
203
 organization first and then redirects the owner to hosted Stripe
203
 organization first and then redirects the owner to hosted Stripe
204
 Checkout; webhook processing, not checkout creation, activates paid
204
 Checkout; webhook processing, not checkout creation, activates paid
205
-entitlements.
205
+entitlements. Stripe success and cancel returns render explicit
206
+post-checkout pages: success explains that Team activation waits for
207
+webhook processing, and cancel leaves the organization on Free with a
208
+path to retry checkout.
206
 Existing owner-managed orgs also link to that billing page from
209
 Existing owner-managed orgs also link to that billing page from
207
 `/settings/organizations` for plan comparison.
210
 `/settings/organizations` for plan comparison.
208
 
211
 
internal/web/handlers/orgs/billing_settings.gomodified
@@ -122,7 +122,7 @@ func (h *Handlers) billingSuccess(w http.ResponseWriter, r *http.Request) {
122
 	if !ok {
122
 	if !ok {
123
 		return
123
 		return
124
 	}
124
 	}
125
-	http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=checkout-success", http.StatusSeeOther)
125
+	h.renderBillingResult(w, r, org, billingResultSuccess)
126
 }
126
 }
127
 
127
 
128
 func (h *Handlers) billingCancel(w http.ResponseWriter, r *http.Request) {
128
 func (h *Handlers) billingCancel(w http.ResponseWriter, r *http.Request) {
@@ -130,7 +130,31 @@ func (h *Handlers) billingCancel(w http.ResponseWriter, r *http.Request) {
130
 	if !ok {
130
 	if !ok {
131
 		return
131
 		return
132
 	}
132
 	}
133
-	http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=checkout-canceled", http.StatusSeeOther)
133
+	h.renderBillingResult(w, r, org, billingResultCanceled)
134
+}
135
+
136
+const (
137
+	billingResultSuccess  = "success"
138
+	billingResultCanceled = "canceled"
139
+)
140
+
141
+func (h *Handlers) renderBillingResult(w http.ResponseWriter, r *http.Request, org orgsdb.Org, result string) {
142
+	heading := "Checkout complete"
143
+	message := "Stripe accepted the checkout session. Team activation finishes after shithub receives and processes the signed Stripe webhook."
144
+	if result == billingResultCanceled {
145
+		heading = "Checkout canceled"
146
+		message = "No Team subscription was activated. The organization stays on Free until checkout is completed."
147
+	}
148
+	_ = h.d.Render.RenderPage(w, r, "orgs/billing_result", map[string]any{
149
+		"Title":       heading,
150
+		"CSRFToken":   middleware.CSRFTokenForRequest(r),
151
+		"Org":         org,
152
+		"AvatarURL":   "/avatars/" + url.PathEscape(org.Slug),
153
+		"Result":      result,
154
+		"Heading":     heading,
155
+		"Message":     message,
156
+		"BillingPath": orgBillingSettingsPath(org.Slug),
157
+	})
134
 }
158
 }
135
 
159
 
136
 func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request, org orgsdb.Org, errMsg, notice string) {
160
 func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request, org orgsdb.Org, errMsg, notice string) {
internal/web/handlers/orgs/billing_test.gomodified
@@ -111,6 +111,32 @@ func TestOrgBillingPortalRedirectsToStripe(t *testing.T) {
111
 	}
111
 	}
112
 }
112
 }
113
 
113
 
114
+func TestOrgBillingResultPagesRenderPostCheckoutState(t *testing.T) {
115
+	t.Parallel()
116
+	pool := dbtest.NewTestDB(t)
117
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
118
+	insertOrgAvatarOrg(t, pool, ownerID, "acme")
119
+	mux := newOrgBillingMux(t, pool, ownerID, &fakeStripeRemote{})
120
+
121
+	for _, tc := range []struct {
122
+		path string
123
+		want string
124
+	}{
125
+		{path: "/organizations/acme/billing/success", want: "RESULT=success;HEADING=Checkout complete"},
126
+		{path: "/organizations/acme/billing/cancel", want: "RESULT=canceled;HEADING=Checkout canceled"},
127
+	} {
128
+		resp := httptest.NewRecorder()
129
+		req := newOrgFormRequest(http.MethodGet, tc.path, nil)
130
+		mux.ServeHTTP(resp, req)
131
+		if resp.Code != http.StatusOK {
132
+			t.Fatalf("%s status=%d body=%s", tc.path, resp.Code, resp.Body.String())
133
+		}
134
+		if !strings.Contains(resp.Body.String(), tc.want) {
135
+			t.Fatalf("%s missing %q in body %s", tc.path, tc.want, resp.Body.String())
136
+		}
137
+	}
138
+}
139
+
114
 func TestOrgBillingWebhookProcessesSubscriptionAndStaysIdempotent(t *testing.T) {
140
 func TestOrgBillingWebhookProcessesSubscriptionAndStaysIdempotent(t *testing.T) {
115
 	t.Parallel()
141
 	t.Parallel()
116
 	ctx := context.Background()
142
 	ctx := context.Background()
@@ -437,6 +463,7 @@ func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote st
437
 	t.Helper()
463
 	t.Helper()
438
 	tmplFS := fstest.MapFS{
464
 	tmplFS := fstest.MapFS{
439
 		"_layout.html":               {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
465
 		"_layout.html":               {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
466
+		"orgs/billing_result.html":   {Data: []byte(`{{ define "page" }}RESULT={{ .Result }};HEADING={{ .Heading }};MESSAGE={{ .Message }};BILLING={{ .BillingPath }}{{ end }}`)},
440
 		"orgs/settings_billing.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Notice }}NOTICE={{ . }}{{ end }}{{ range .Invoices }}INVOICE={{ .Number }};{{ end }}{{ end }}`)},
467
 		"orgs/settings_billing.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Notice }}NOTICE={{ . }}{{ end }}{{ range .Invoices }}INVOICE={{ .Number }};{{ end }}{{ end }}`)},
441
 		"errors/403.html":            {Data: []byte(`{{ define "page" }}403{{ end }}`)},
468
 		"errors/403.html":            {Data: []byte(`{{ define "page" }}403{{ end }}`)},
442
 		"errors/404.html":            {Data: []byte(`{{ define "page" }}404{{ end }}`)},
469
 		"errors/404.html":            {Data: []byte(`{{ define "page" }}404{{ end }}`)},
internal/web/static/css/shithub.cssmodified
@@ -1192,6 +1192,36 @@ code {
1192
   color: var(--fg-muted);
1192
   color: var(--fg-muted);
1193
   font-size: 0.9rem;
1193
   font-size: 0.9rem;
1194
 }
1194
 }
1195
+.shithub-billing-result {
1196
+  display: flex;
1197
+  justify-content: center;
1198
+  padding: 4rem 1rem;
1199
+}
1200
+.shithub-billing-result-card {
1201
+  width: min(100%, 34rem);
1202
+  padding: 2rem;
1203
+  text-align: center;
1204
+  background: var(--canvas-default);
1205
+  border: 1px solid var(--border-default);
1206
+  border-radius: 6px;
1207
+}
1208
+.shithub-billing-result-card img {
1209
+  border-radius: 50%;
1210
+}
1211
+.shithub-billing-result-card h1 {
1212
+  margin: 0.5rem 0 0.75rem;
1213
+  font-size: 1.6rem;
1214
+}
1215
+.shithub-billing-result-card p {
1216
+  color: var(--fg-muted);
1217
+}
1218
+.shithub-billing-result-actions {
1219
+  display: flex;
1220
+  flex-wrap: wrap;
1221
+  justify-content: center;
1222
+  gap: 0.75rem;
1223
+  margin-top: 1.5rem;
1224
+}
1195
 .shithub-org-billing-compare {
1225
 .shithub-org-billing-compare {
1196
   margin-top: 0;
1226
   margin-top: 0;
1197
 }
1227
 }
internal/web/templates/orgs/billing_result.htmladded
@@ -0,0 +1,23 @@
1
+{{ define "page" -}}
2
+<section class="shithub-billing-result">
3
+  <div class="shithub-billing-result-card">
4
+    <img src="{{ .AvatarURL }}" alt="" width="48" height="48">
5
+    <p class="shithub-org-plan-kicker">{{ if eq .Result "success" }}Payment setup{{ else }}Team checkout{{ end }}</p>
6
+    <h1>{{ .Heading }}</h1>
7
+    <p>{{ .Message }}</p>
8
+
9
+    <div class="shithub-billing-result-actions">
10
+      {{ if eq .Result "success" }}
11
+      <a href="{{ .BillingPath }}" class="shithub-button shithub-button-primary">View billing and plans</a>
12
+      <a href="/{{ .Org.Slug }}" class="shithub-button">Go to organization</a>
13
+      {{ else }}
14
+      <form method="POST" action="/organizations/{{ .Org.Slug }}/billing/checkout">
15
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
16
+        <button type="submit" class="shithub-button shithub-button-primary">Continue with Team</button>
17
+      </form>
18
+      <a href="{{ .BillingPath }}" class="shithub-button">Keep Free for now</a>
19
+      {{ end }}
20
+    </div>
21
+  </div>
22
+</section>
23
+{{- end }}