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 | 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 }} | ||