tenseleyflow/shithub / 4a186b5

Browse files

Start Team checkout during org creation

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
4a186b5b13d415a859e874c8de0ce13afa871177
Parents
5f78996
Tree
f5e60bd

6 changed files

StatusFile+-
M docs/internal/billing.md 1 1
M docs/internal/orgs.md 3 2
M docs/internal/runbooks/stripe-billing.md 5 6
M internal/web/handlers/orgs/billing_settings.go 17 13
M internal/web/handlers/orgs/create_test.go 4 4
M internal/web/handlers/orgs/orgs.go 12 2
docs/internal/billing.mdmodified
@@ -31,7 +31,7 @@ Initial decisions:
3131
   hierarchy, and contracts are deferred.
3232
 - Self-serve organization creation presents `/organizations/plan` as
3333
   the canonical plan selector. Choosing Team creates the organization
34
-  and then hands the owner into billing to finish checkout.
34
+  and immediately redirects the owner to hosted Stripe Checkout.
3535
 
3636
 The fairness rule is explicit: public/open-source collaboration should
3737
 stay generous. Paid gates focus on private collaboration, hosted cost,
docs/internal/orgs.mdmodified
@@ -200,8 +200,9 @@ the human-facing summary, but product behavior goes through
200200
 picker. Existing "New organization" links route there. When Stripe
201201
 Billing is not fully configured, Team remains visible but disabled;
202202
 site admins see operator setup copy. Choosing Team creates the
203
-organization first and then redirects the owner to
204
-`/organizations/{org}/settings/billing` to begin Stripe Checkout.
203
+organization first and then redirects the owner to hosted Stripe
204
+Checkout; webhook processing, not checkout creation, activates paid
205
+entitlements.
205206
 Existing owner-managed orgs also link to that billing page from
206207
 `/settings/organizations` for plan comparison.
207208
 
docs/internal/runbooks/stripe-billing.mdmodified
@@ -80,16 +80,15 @@ worker uses the same Stripe credentials for seat quantity sync.
8080
 2. Visit `/organizations/plan`.
8181
 3. Confirm the plan picker appears.
8282
 4. Choose Team, create a test organization, and confirm redirect to
83
-   `/organizations/<org>/settings/billing?notice=team-created`.
84
-5. Click `Upgrade to Team`.
85
-6. Complete Stripe Checkout with a test card.
86
-7. Confirm Stripe redirects back to shithub.
87
-8. Confirm `/organizations/<org>/settings/billing` eventually shows:
83
+   hosted Stripe Checkout.
84
+5. Complete Stripe Checkout with a test card.
85
+6. Confirm Stripe redirects back to shithub.
86
+7. Confirm `/organizations/<org>/settings/billing` eventually shows:
8887
    - current plan: Team,
8988
    - subscription: Active,
9089
    - payment source: Stripe customer configured,
9190
    - a billable member count matching the org membership.
92
-9. Invite or remove a member and verify the worker updates the Stripe
91
+8. Invite or remove a member and verify the worker updates the Stripe
9392
    subscription item quantity.
9493
 
9594
 If the UI remains Free after checkout, inspect webhook receipts and the
internal/web/handlers/orgs/billing_settings.gomodified
@@ -50,23 +50,27 @@ func (h *Handlers) billingCheckout(w http.ResponseWriter, r *http.Request) {
5050
 		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
5151
 		return
5252
 	}
53
-	state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
53
+	sessionURL, err := h.startBillingCheckout(r, org)
5454
 	if err != nil {
55
-		h.d.Logger.ErrorContext(r.Context(), "org billing: load state for checkout", "org_id", org.ID, "error", err)
56
-		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
55
+		h.d.Logger.ErrorContext(r.Context(), "org billing: create checkout", "org_id", org.ID, "error", err)
56
+		h.renderSettingsBilling(w, r, org, "Could not start checkout right now.", "")
5757
 		return
5858
 	}
59
+	http.Redirect(w, r, sessionURL, http.StatusSeeOther)
60
+}
61
+
62
+func (h *Handlers) startBillingCheckout(r *http.Request, org orgsdb.Org) (string, error) {
63
+	state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
64
+	if err != nil {
65
+		return "", fmt.Errorf("load billing state: %w", err)
66
+	}
5967
 	state, err = h.ensureStripeCustomer(r, org, state)
6068
 	if err != nil {
61
-		h.d.Logger.ErrorContext(r.Context(), "org billing: ensure stripe customer", "org_id", org.ID, "error", err)
62
-		h.renderSettingsBilling(w, r, org, "Could not start checkout right now.", "")
63
-		return
69
+		return "", fmt.Errorf("ensure stripe customer: %w", err)
6470
 	}
6571
 	seats, err := orgbilling.CountBillableOrgMembers(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
6672
 	if err != nil {
67
-		h.d.Logger.ErrorContext(r.Context(), "org billing: count seats", "org_id", org.ID, "error", err)
68
-		h.renderSettingsBilling(w, r, org, "Could not calculate billable seats right now.", "")
69
-		return
73
+		return "", fmt.Errorf("count billable seats: %w", err)
7074
 	}
7175
 	session, err := h.d.Stripe.CreateCheckoutSession(r.Context(), stripebilling.CheckoutInput{
7276
 		OrgID:      org.ID,
@@ -77,11 +81,9 @@ func (h *Handlers) billingCheckout(w http.ResponseWriter, r *http.Request) {
7781
 		CancelURL:  h.billingReturnURL(org.Slug, h.d.StripeCancelURL, "/organizations/"+org.Slug+"/billing/cancel"),
7882
 	})
7983
 	if err != nil {
80
-		h.d.Logger.ErrorContext(r.Context(), "org billing: create checkout session", "org_id", org.ID, "error", err)
81
-		h.renderSettingsBilling(w, r, org, "Could not create the Stripe checkout session.", "")
82
-		return
84
+		return "", fmt.Errorf("create stripe checkout session: %w", err)
8385
 	}
84
-	http.Redirect(w, r, session.URL, http.StatusSeeOther)
86
+	return session.URL, nil
8587
 }
8688
 
8789
 func (h *Handlers) billingPortal(w http.ResponseWriter, r *http.Request) {
@@ -212,6 +214,8 @@ func billingNotice(code string) string {
212214
 		return "Organization created. Continue with Team checkout to unlock paid features."
213215
 	case "team-created-import-started":
214216
 		return "Organization created and GitHub import started. Continue with Team checkout to unlock paid features."
217
+	case "team-checkout-failed":
218
+		return "Organization created, but checkout could not be started. Try Continue with Team again."
215219
 	default:
216220
 		return ""
217221
 	}
internal/web/handlers/orgs/create_test.gomodified
@@ -139,7 +139,7 @@ func TestOrgCreateRequiresTermsAcceptance(t *testing.T) {
139139
 	}
140140
 }
141141
 
142
-func TestOrgCreateTeamPlanRedirectsToBillingSettings(t *testing.T) {
142
+func TestOrgCreateTeamPlanRedirectsToCheckout(t *testing.T) {
143143
 	t.Parallel()
144144
 	srv, pool := newOrgCreateServer(t, true)
145145
 	t.Cleanup(srv.Close)
@@ -161,7 +161,7 @@ func TestOrgCreateTeamPlanRedirectsToBillingSettings(t *testing.T) {
161161
 	if resp.StatusCode != http.StatusSeeOther {
162162
 		t.Fatalf("status=%d", resp.StatusCode)
163163
 	}
164
-	if got := resp.Header.Get("Location"); got != "/organizations/acme/settings/billing?notice=team-created" {
164
+	if got := resp.Header.Get("Location"); got != "https://checkout.stripe.test/org-create" {
165165
 		t.Fatalf("redirect location=%q", got)
166166
 	}
167167
 
@@ -222,11 +222,11 @@ func newOrgCreateServer(t *testing.T, billingEnabled bool) (*httptest.Server, *p
222222
 type noOpStripeRemote struct{}
223223
 
224224
 func (noOpStripeRemote) CreateCustomer(context.Context, stripebilling.CustomerInput) (stripebilling.Customer, error) {
225
-	return stripebilling.Customer{}, nil
225
+	return stripebilling.Customer{ID: "cus_test_org_create"}, nil
226226
 }
227227
 
228228
 func (noOpStripeRemote) CreateCheckoutSession(context.Context, stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error) {
229
-	return stripebilling.CheckoutSession{}, nil
229
+	return stripebilling.CheckoutSession{ID: "cs_test_org_create", URL: "https://checkout.stripe.test/org-create"}, nil
230230
 }
231231
 
232232
 func (noOpStripeRemote) CreatePortalSession(context.Context, stripebilling.PortalInput) (stripebilling.PortalSession, error) {
internal/web/handlers/orgs/orgs.gomodified
@@ -284,19 +284,29 @@ func (h *Handlers) createSubmit(w http.ResponseWriter, r *http.Request) {
284284
 			return
285285
 		}
286286
 		if form.SelectedTier == orgCreatePlanTeam && h.billingConfigured() {
287
-			http.Redirect(w, r, orgBillingSettingsPath(row.Slug)+"?notice=team-created-import-started", http.StatusSeeOther)
287
+			h.redirectToTeamCheckout(w, r, row)
288288
 			return
289289
 		}
290290
 		http.Redirect(w, r, "/organizations/"+row.Slug+"/imports/"+strconv.FormatInt(imp.ID, 10), http.StatusSeeOther)
291291
 		return
292292
 	}
293293
 	if form.SelectedTier == orgCreatePlanTeam && h.billingConfigured() {
294
-		http.Redirect(w, r, orgBillingSettingsPath(row.Slug)+"?notice=team-created", http.StatusSeeOther)
294
+		h.redirectToTeamCheckout(w, r, row)
295295
 		return
296296
 	}
297297
 	http.Redirect(w, r, "/"+row.Slug, http.StatusSeeOther)
298298
 }
299299
 
300
+func (h *Handlers) redirectToTeamCheckout(w http.ResponseWriter, r *http.Request, org orgsdb.Org) {
301
+	sessionURL, err := h.startBillingCheckout(r, org)
302
+	if err != nil {
303
+		h.d.Logger.ErrorContext(r.Context(), "orgs: start team checkout after create", "org_id", org.ID, "error", err)
304
+		http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=team-checkout-failed", http.StatusSeeOther)
305
+		return
306
+	}
307
+	http.Redirect(w, r, sessionURL, http.StatusSeeOther)
308
+}
309
+
300310
 func (f orgCreateForm) withoutToken() orgCreateForm {
301311
 	f.GitHubToken = ""
302312
 	return f