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:
169169
 The operator enablement flow is documented in
170170
 [`runbooks/stripe-billing.md`](./runbooks/stripe-billing.md).
171171
 
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
+
172183
 ## Entitlement architecture
173184
 
174185
 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;
202202
 site admins see operator setup copy. Choosing Team creates the
203203
 organization first and then redirects the owner to hosted Stripe
204204
 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.
206209
 Existing owner-managed orgs also link to that billing page from
207210
 `/settings/organizations` for plan comparison.
208211
 
internal/web/handlers/orgs/billing_settings.gomodified
@@ -122,7 +122,7 @@ func (h *Handlers) billingSuccess(w http.ResponseWriter, r *http.Request) {
122122
 	if !ok {
123123
 		return
124124
 	}
125
-	http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=checkout-success", http.StatusSeeOther)
125
+	h.renderBillingResult(w, r, org, billingResultSuccess)
126126
 }
127127
 
128128
 func (h *Handlers) billingCancel(w http.ResponseWriter, r *http.Request) {
@@ -130,7 +130,31 @@ func (h *Handlers) billingCancel(w http.ResponseWriter, r *http.Request) {
130130
 	if !ok {
131131
 		return
132132
 	}
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
+	})
134158
 }
135159
 
136160
 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) {
111111
 	}
112112
 }
113113
 
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
+
114140
 func TestOrgBillingWebhookProcessesSubscriptionAndStaysIdempotent(t *testing.T) {
115141
 	t.Parallel()
116142
 	ctx := context.Background()
@@ -437,6 +463,7 @@ func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote st
437463
 	t.Helper()
438464
 	tmplFS := fstest.MapFS{
439465
 		"_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 }}`)},
440467
 		"orgs/settings_billing.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Notice }}NOTICE={{ . }}{{ end }}{{ range .Invoices }}INVOICE={{ .Number }};{{ end }}{{ end }}`)},
441468
 		"errors/403.html":            {Data: []byte(`{{ define "page" }}403{{ end }}`)},
442469
 		"errors/404.html":            {Data: []byte(`{{ define "page" }}404{{ end }}`)},
internal/web/static/css/shithub.cssmodified
@@ -1192,6 +1192,36 @@ code {
11921192
   color: var(--fg-muted);
11931193
   font-size: 0.9rem;
11941194
 }
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
+}
11951225
 .shithub-org-billing-compare {
11961226
   margin-top: 0;
11971227
 }
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 }}