Add org billing checkout result pages
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
122ca19fe434b67ab6a3065f184bef84be9e9819- Parents
-
4a186b5 - Tree
7e5468e
122ca19
122ca19fe434b67ab6a3065f184bef84be9e98194a186b5
7e5468e| Status | File | + | - |
|---|---|---|---|
| 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 | 169 | The operator enablement flow is documented in |
| 170 | 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 | 183 | ## Entitlement architecture |
| 173 | 184 | |
| 174 | 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 | 202 | site admins see operator setup copy. Choosing Team creates the |
| 203 | 203 | organization first and then redirects the owner to hosted Stripe |
| 204 | 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 | 209 | Existing owner-managed orgs also link to that billing page from |
| 207 | 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 | 122 | if !ok { |
| 123 | 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 | 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 | 130 | if !ok { |
| 131 | 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 | 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 | 140 | func TestOrgBillingWebhookProcessesSubscriptionAndStaysIdempotent(t *testing.T) { |
| 115 | 141 | t.Parallel() |
| 116 | 142 | ctx := context.Background() |
@@ -437,6 +463,7 @@ func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote st | ||
| 437 | 463 | t.Helper() |
| 438 | 464 | tmplFS := fstest.MapFS{ |
| 439 | 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 | 467 | "orgs/settings_billing.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Notice }}NOTICE={{ . }}{{ end }}{{ range .Invoices }}INVOICE={{ .Number }};{{ end }}{{ end }}`)}, |
| 441 | 468 | "errors/403.html": {Data: []byte(`{{ define "page" }}403{{ end }}`)}, |
| 442 | 469 | "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)}, |
internal/web/static/css/shithub.cssmodified@@ -1192,6 +1192,36 @@ code { | ||
| 1192 | 1192 | color: var(--fg-muted); |
| 1193 | 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 | 1225 | .shithub-org-billing-compare { |
| 1196 | 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 }} | |