Add Stripe billing web routes
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
3c68bbc30861a8b81f7a8ab82b92ca091ddfe9ed- Parents
-
c6777a6 - Tree
420de56
3c68bbc
3c68bbc30861a8b81f7a8ab82b92ca091ddfe9edc6777a6
420de56internal/web/handlers/handlers.gomodified@@ -132,6 +132,10 @@ type Deps struct { | ||
| 132 | 132 | // needed, so the route lives in the public group alongside |
| 133 | 133 | // /healthz / /static. |
| 134 | 134 | NotifPublicMounter func(chi.Router) |
| 135 | + // BillingWebhookMounter registers the Stripe webhook receiver at | |
| 136 | + // /stripe/webhook. It lives in the public, CSRF-exempt group and is | |
| 137 | + // mounted only when billing is enabled and fully configured. | |
| 138 | + BillingWebhookMounter func(chi.Router) | |
| 135 | 139 | // OrgCreateMounter registers /organizations/new + POST |
| 136 | 140 | // /organizations (S30). Wrapped in RequireUser at the wiring |
| 137 | 141 | // layer. |
@@ -246,6 +250,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http | ||
| 246 | 250 | if deps.NotifPublicMounter != nil { |
| 247 | 251 | deps.NotifPublicMounter(r) |
| 248 | 252 | } |
| 253 | + if deps.BillingWebhookMounter != nil { | |
| 254 | + deps.BillingWebhookMounter(r) | |
| 255 | + } | |
| 249 | 256 | }) |
| 250 | 257 | |
| 251 | 258 | // Smart-HTTP git routes get their own group: NO CSRF (HTTP Basic |
internal/web/handlers/orgs/billing_settings.goadded@@ -0,0 +1,408 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package orgs | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "fmt" | |
| 7 | + "net/http" | |
| 8 | + "net/url" | |
| 9 | + "strings" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + orgbilling "github.com/tenseleyFlow/shithub/internal/billing" | |
| 13 | + billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc" | |
| 14 | + "github.com/tenseleyFlow/shithub/internal/billing/stripebilling" | |
| 15 | + orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" | |
| 16 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" | |
| 17 | +) | |
| 18 | + | |
| 19 | +type billingSummaryItem struct { | |
| 20 | + Label string | |
| 21 | + Value string | |
| 22 | + Detail string | |
| 23 | +} | |
| 24 | + | |
| 25 | +type billingInvoiceView struct { | |
| 26 | + Number string | |
| 27 | + StatusLabel string | |
| 28 | + StatusClass string | |
| 29 | + AmountLabel string | |
| 30 | + PeriodLabel string | |
| 31 | + DueLabel string | |
| 32 | + HostedInvoiceURL string | |
| 33 | + InvoicePDFURL string | |
| 34 | +} | |
| 35 | + | |
| 36 | +func (h *Handlers) settingsBilling(w http.ResponseWriter, r *http.Request) { | |
| 37 | + org, ok := h.loadOrgSettingsOwner(w, r) | |
| 38 | + if !ok { | |
| 39 | + return | |
| 40 | + } | |
| 41 | + h.renderSettingsBilling(w, r, org, "", billingNotice(r.URL.Query().Get("notice"))) | |
| 42 | +} | |
| 43 | + | |
| 44 | +func (h *Handlers) billingCheckout(w http.ResponseWriter, r *http.Request) { | |
| 45 | + org, ok := h.loadOrgSettingsOwner(w, r) | |
| 46 | + if !ok { | |
| 47 | + return | |
| 48 | + } | |
| 49 | + if !h.billingConfigured() { | |
| 50 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | |
| 51 | + return | |
| 52 | + } | |
| 53 | + state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID) | |
| 54 | + 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, "") | |
| 57 | + return | |
| 58 | + } | |
| 59 | + state, err = h.ensureStripeCustomer(r, org, state) | |
| 60 | + 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 | |
| 64 | + } | |
| 65 | + seats, err := orgbilling.CountBillableOrgMembers(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID) | |
| 66 | + 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 | |
| 70 | + } | |
| 71 | + session, err := h.d.Stripe.CreateCheckoutSession(r.Context(), stripebilling.CheckoutInput{ | |
| 72 | + OrgID: org.ID, | |
| 73 | + OrgSlug: org.Slug, | |
| 74 | + CustomerID: state.StripeCustomerID.String, | |
| 75 | + SeatCount: int64(seats), | |
| 76 | + SuccessURL: h.billingReturnURL(org.Slug, h.d.StripeSuccessURL, "/organizations/"+org.Slug+"/billing/success"), | |
| 77 | + CancelURL: h.billingReturnURL(org.Slug, h.d.StripeCancelURL, "/organizations/"+org.Slug+"/billing/cancel"), | |
| 78 | + }) | |
| 79 | + 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 | |
| 83 | + } | |
| 84 | + http.Redirect(w, r, session.URL, http.StatusSeeOther) | |
| 85 | +} | |
| 86 | + | |
| 87 | +func (h *Handlers) billingPortal(w http.ResponseWriter, r *http.Request) { | |
| 88 | + org, ok := h.loadOrgSettingsOwner(w, r) | |
| 89 | + if !ok { | |
| 90 | + return | |
| 91 | + } | |
| 92 | + if !h.billingConfigured() { | |
| 93 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | |
| 94 | + return | |
| 95 | + } | |
| 96 | + state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID) | |
| 97 | + if err != nil { | |
| 98 | + h.d.Logger.ErrorContext(r.Context(), "org billing: load state for portal", "org_id", org.ID, "error", err) | |
| 99 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 100 | + return | |
| 101 | + } | |
| 102 | + if !state.StripeCustomerID.Valid || strings.TrimSpace(state.StripeCustomerID.String) == "" { | |
| 103 | + h.renderSettingsBilling(w, r, org, "Billing portal is unavailable until this organization has a Stripe customer record.", "") | |
| 104 | + return | |
| 105 | + } | |
| 106 | + session, err := h.d.Stripe.CreatePortalSession(r.Context(), stripebilling.PortalInput{ | |
| 107 | + CustomerID: state.StripeCustomerID.String, | |
| 108 | + ReturnURL: h.billingReturnURL(org.Slug, h.d.StripePortalReturnURL, orgBillingSettingsPath(org.Slug)), | |
| 109 | + }) | |
| 110 | + if err != nil { | |
| 111 | + h.d.Logger.ErrorContext(r.Context(), "org billing: create portal session", "org_id", org.ID, "error", err) | |
| 112 | + h.renderSettingsBilling(w, r, org, "Could not open the Stripe billing portal right now.", "") | |
| 113 | + return | |
| 114 | + } | |
| 115 | + http.Redirect(w, r, session.URL, http.StatusSeeOther) | |
| 116 | +} | |
| 117 | + | |
| 118 | +func (h *Handlers) billingSuccess(w http.ResponseWriter, r *http.Request) { | |
| 119 | + org, ok := h.loadOrgSettingsOwner(w, r) | |
| 120 | + if !ok { | |
| 121 | + return | |
| 122 | + } | |
| 123 | + http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=checkout-success", http.StatusSeeOther) | |
| 124 | +} | |
| 125 | + | |
| 126 | +func (h *Handlers) billingCancel(w http.ResponseWriter, r *http.Request) { | |
| 127 | + org, ok := h.loadOrgSettingsOwner(w, r) | |
| 128 | + if !ok { | |
| 129 | + return | |
| 130 | + } | |
| 131 | + http.Redirect(w, r, orgBillingSettingsPath(org.Slug)+"?notice=checkout-canceled", http.StatusSeeOther) | |
| 132 | +} | |
| 133 | + | |
| 134 | +func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request, org orgsdb.Org, errMsg, notice string) { | |
| 135 | + state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID) | |
| 136 | + if err != nil { | |
| 137 | + h.d.Logger.ErrorContext(r.Context(), "org billing: load state", "org_id", org.ID, "error", err) | |
| 138 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") | |
| 139 | + return | |
| 140 | + } | |
| 141 | + memberCount, err := orgbilling.CountBillableOrgMembers(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID) | |
| 142 | + if err != nil { | |
| 143 | + h.d.Logger.WarnContext(r.Context(), "org billing: count members", "org_id", org.ID, "error", err) | |
| 144 | + memberCount = int(state.BillableSeats) | |
| 145 | + } | |
| 146 | + invoices, err := orgbilling.ListInvoicesForOrg(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, 10) | |
| 147 | + if err != nil { | |
| 148 | + h.d.Logger.WarnContext(r.Context(), "org billing: list invoices", "org_id", org.ID, "error", err) | |
| 149 | + invoices = nil | |
| 150 | + } | |
| 151 | + _ = h.d.Render.RenderPage(w, r, "orgs/settings_billing", map[string]any{ | |
| 152 | + "Title": org.Slug + " - billing and plans", | |
| 153 | + "CSRFToken": middleware.CSRFTokenForRequest(r), | |
| 154 | + "Org": org, | |
| 155 | + "AvatarURL": "/avatars/" + url.PathEscape(org.Slug), | |
| 156 | + "ActiveOrgNav": "settings", | |
| 157 | + "OrgSettingsActive": "billing", | |
| 158 | + "BillingEnabled": h.d.BillingEnabled, | |
| 159 | + "Error": errMsg, | |
| 160 | + "Notice": notice, | |
| 161 | + "Summary": billingSummary(state, memberCount), | |
| 162 | + "CanStartCheckout": h.billingConfigured(), | |
| 163 | + "CanManageSubscription": h.billingConfigured() && state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "", | |
| 164 | + "GracePeriodLabel": formatGracePeriod(h.d.BillingGracePeriod), | |
| 165 | + "Invoices": billingInvoiceViews(invoices), | |
| 166 | + }) | |
| 167 | +} | |
| 168 | + | |
| 169 | +func (h *Handlers) ensureStripeCustomer(r *http.Request, org orgsdb.Org, state orgbilling.State) (orgbilling.State, error) { | |
| 170 | + if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" { | |
| 171 | + return state, nil | |
| 172 | + } | |
| 173 | + customer, err := h.d.Stripe.CreateCustomer(r.Context(), stripebilling.CustomerInput{ | |
| 174 | + OrgID: org.ID, | |
| 175 | + OrgSlug: org.Slug, | |
| 176 | + OrgName: strings.TrimSpace(org.DisplayName), | |
| 177 | + Email: strings.TrimSpace(org.BillingEmail), | |
| 178 | + }) | |
| 179 | + if err != nil { | |
| 180 | + return orgbilling.State{}, err | |
| 181 | + } | |
| 182 | + return orgbilling.SetStripeCustomer(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, customer.ID) | |
| 183 | +} | |
| 184 | + | |
| 185 | +func (h *Handlers) billingReturnURL(orgSlug, overrideURL, fallbackPath string) string { | |
| 186 | + overrideURL = strings.TrimSpace(overrideURL) | |
| 187 | + if overrideURL != "" { | |
| 188 | + return strings.ReplaceAll(overrideURL, "{org}", url.PathEscape(orgSlug)) | |
| 189 | + } | |
| 190 | + base, err := url.Parse(strings.TrimRight(h.d.BaseURL, "/") + "/") | |
| 191 | + if err != nil { | |
| 192 | + return "" | |
| 193 | + } | |
| 194 | + rel, err := url.Parse(strings.TrimLeft(fallbackPath, "/")) | |
| 195 | + if err != nil { | |
| 196 | + return "" | |
| 197 | + } | |
| 198 | + return base.ResolveReference(rel).String() | |
| 199 | +} | |
| 200 | + | |
| 201 | +func orgBillingSettingsPath(slug string) string { | |
| 202 | + return "/organizations/" + slug + "/settings/billing" | |
| 203 | +} | |
| 204 | + | |
| 205 | +func billingNotice(code string) string { | |
| 206 | + switch code { | |
| 207 | + case "checkout-success": | |
| 208 | + return "Checkout completed. Stripe will finish provisioning as webhook events arrive." | |
| 209 | + case "checkout-canceled": | |
| 210 | + return "Checkout canceled." | |
| 211 | + default: | |
| 212 | + return "" | |
| 213 | + } | |
| 214 | +} | |
| 215 | + | |
| 216 | +func billingSummary(state orgbilling.State, memberCount int) []billingSummaryItem { | |
| 217 | + summary := []billingSummaryItem{ | |
| 218 | + { | |
| 219 | + Label: "Current plan", | |
| 220 | + Value: billingPlanLabel(state.Plan), | |
| 221 | + Detail: billingPlanDetail(state), | |
| 222 | + }, | |
| 223 | + { | |
| 224 | + Label: "Subscription", | |
| 225 | + Value: billingStatusLabel(state.SubscriptionStatus), | |
| 226 | + Detail: billingStatusDetail(state), | |
| 227 | + }, | |
| 228 | + { | |
| 229 | + Label: "Billable members", | |
| 230 | + Value: fmt.Sprintf("%d", memberCount), | |
| 231 | + Detail: billingSeatDetail(state), | |
| 232 | + }, | |
| 233 | + { | |
| 234 | + Label: "Payment source", | |
| 235 | + Value: billingPaymentSourceLabel(state), | |
| 236 | + Detail: billingPaymentSourceDetail(state), | |
| 237 | + }, | |
| 238 | + } | |
| 239 | + return summary | |
| 240 | +} | |
| 241 | + | |
| 242 | +func billingPlanLabel(plan orgbilling.Plan) string { | |
| 243 | + switch plan { | |
| 244 | + case orgbilling.PlanTeam: | |
| 245 | + return "Team" | |
| 246 | + case orgbilling.PlanEnterprise: | |
| 247 | + return "Enterprise" | |
| 248 | + default: | |
| 249 | + return "Free" | |
| 250 | + } | |
| 251 | +} | |
| 252 | + | |
| 253 | +func billingPlanDetail(state orgbilling.State) string { | |
| 254 | + if state.CurrentPeriodEnd.Valid { | |
| 255 | + label := "Current period ends" | |
| 256 | + if state.CancelAtPeriodEnd { | |
| 257 | + label = "Scheduled to cancel at period end" | |
| 258 | + } | |
| 259 | + return label + " " + state.CurrentPeriodEnd.Time.Format("Jan 2, 2006") | |
| 260 | + } | |
| 261 | + if state.Plan == orgbilling.PlanFree { | |
| 262 | + return "No active paid subscription." | |
| 263 | + } | |
| 264 | + return "" | |
| 265 | +} | |
| 266 | + | |
| 267 | +func billingStatusLabel(status orgbilling.SubscriptionStatus) string { | |
| 268 | + switch status { | |
| 269 | + case orgbilling.SubscriptionStatusActive: | |
| 270 | + return "Active" | |
| 271 | + case orgbilling.SubscriptionStatusTrialing: | |
| 272 | + return "Trialing" | |
| 273 | + case orgbilling.SubscriptionStatusIncomplete: | |
| 274 | + return "Incomplete" | |
| 275 | + case orgbilling.SubscriptionStatusPastDue: | |
| 276 | + return "Past due" | |
| 277 | + case orgbilling.SubscriptionStatusCanceled: | |
| 278 | + return "Canceled" | |
| 279 | + case orgbilling.SubscriptionStatusUnpaid: | |
| 280 | + return "Unpaid" | |
| 281 | + case orgbilling.SubscriptionStatusPaused: | |
| 282 | + return "Paused" | |
| 283 | + default: | |
| 284 | + return "No subscription" | |
| 285 | + } | |
| 286 | +} | |
| 287 | + | |
| 288 | +func billingStatusDetail(state orgbilling.State) string { | |
| 289 | + if state.GraceUntil.Valid { | |
| 290 | + return "Grace period until " + state.GraceUntil.Time.Format("Jan 2, 2006") | |
| 291 | + } | |
| 292 | + if state.CanceledAt.Valid { | |
| 293 | + return "Canceled " + state.CanceledAt.Time.Format("Jan 2, 2006") | |
| 294 | + } | |
| 295 | + if state.TrialEnd.Valid { | |
| 296 | + return "Trial ends " + state.TrialEnd.Time.Format("Jan 2, 2006") | |
| 297 | + } | |
| 298 | + return "" | |
| 299 | +} | |
| 300 | + | |
| 301 | +func billingSeatDetail(state orgbilling.State) string { | |
| 302 | + if state.SeatSnapshotAt.Valid { | |
| 303 | + return fmt.Sprintf("Latest billed seat snapshot: %d captured %s", state.BillableSeats, state.SeatSnapshotAt.Time.Format("Jan 2, 2006")) | |
| 304 | + } | |
| 305 | + if state.BillableSeats > 0 { | |
| 306 | + return fmt.Sprintf("Latest billed seat snapshot: %d", state.BillableSeats) | |
| 307 | + } | |
| 308 | + return "Seat sync has not recorded a snapshot yet." | |
| 309 | +} | |
| 310 | + | |
| 311 | +func billingPaymentSourceLabel(state orgbilling.State) string { | |
| 312 | + if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" { | |
| 313 | + return "Stripe customer connected" | |
| 314 | + } | |
| 315 | + return "Not connected" | |
| 316 | +} | |
| 317 | + | |
| 318 | +func billingPaymentSourceDetail(state orgbilling.State) string { | |
| 319 | + if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" { | |
| 320 | + return state.StripeCustomerID.String | |
| 321 | + } | |
| 322 | + return "Checkout creates a customer record the first time this organization upgrades." | |
| 323 | +} | |
| 324 | + | |
| 325 | +func billingInvoiceViews(invoices []billingdb.BillingInvoice) []billingInvoiceView { | |
| 326 | + items := make([]billingInvoiceView, 0, len(invoices)) | |
| 327 | + for _, inv := range invoices { | |
| 328 | + number := strings.TrimSpace(inv.Number) | |
| 329 | + if number == "" { | |
| 330 | + number = inv.StripeInvoiceID | |
| 331 | + } | |
| 332 | + items = append(items, billingInvoiceView{ | |
| 333 | + Number: number, | |
| 334 | + StatusLabel: billingInvoiceStatusLabel(inv.Status), | |
| 335 | + StatusClass: strings.ReplaceAll(strings.ToLower(string(inv.Status)), "_", "-"), | |
| 336 | + AmountLabel: formatCurrencyAmount(inv.Currency, inv.AmountDueCents), | |
| 337 | + PeriodLabel: billingPeriodLabel(inv), | |
| 338 | + DueLabel: billingDueLabel(inv), | |
| 339 | + HostedInvoiceURL: strings.TrimSpace(inv.HostedInvoiceUrl), | |
| 340 | + InvoicePDFURL: strings.TrimSpace(inv.InvoicePdfUrl), | |
| 341 | + }) | |
| 342 | + } | |
| 343 | + return items | |
| 344 | +} | |
| 345 | + | |
| 346 | +func billingInvoiceStatusLabel(status orgbilling.InvoiceStatus) string { | |
| 347 | + switch status { | |
| 348 | + case orgbilling.InvoiceStatusOpen: | |
| 349 | + return "Open" | |
| 350 | + case orgbilling.InvoiceStatusPaid: | |
| 351 | + return "Paid" | |
| 352 | + case orgbilling.InvoiceStatusVoid: | |
| 353 | + return "Void" | |
| 354 | + case orgbilling.InvoiceStatusUncollectible: | |
| 355 | + return "Uncollectible" | |
| 356 | + default: | |
| 357 | + return "Draft" | |
| 358 | + } | |
| 359 | +} | |
| 360 | + | |
| 361 | +func billingPeriodLabel(inv billingdb.BillingInvoice) string { | |
| 362 | + if inv.PeriodStart.Valid && inv.PeriodEnd.Valid { | |
| 363 | + return inv.PeriodStart.Time.Format("Jan 2, 2006") + " - " + inv.PeriodEnd.Time.Format("Jan 2, 2006") | |
| 364 | + } | |
| 365 | + return "—" | |
| 366 | +} | |
| 367 | + | |
| 368 | +func billingDueLabel(inv billingdb.BillingInvoice) string { | |
| 369 | + switch { | |
| 370 | + case inv.PaidAt.Valid: | |
| 371 | + return "Paid " + inv.PaidAt.Time.Format("Jan 2, 2006") | |
| 372 | + case inv.VoidedAt.Valid: | |
| 373 | + return "Voided " + inv.VoidedAt.Time.Format("Jan 2, 2006") | |
| 374 | + case inv.DueAt.Valid: | |
| 375 | + return inv.DueAt.Time.Format("Jan 2, 2006") | |
| 376 | + default: | |
| 377 | + return "—" | |
| 378 | + } | |
| 379 | +} | |
| 380 | + | |
| 381 | +func formatGracePeriod(d time.Duration) string { | |
| 382 | + if d <= 0 { | |
| 383 | + return "No grace period" | |
| 384 | + } | |
| 385 | + if d%(24*time.Hour) == 0 { | |
| 386 | + days := int(d / (24 * time.Hour)) | |
| 387 | + if days == 1 { | |
| 388 | + return "1 day" | |
| 389 | + } | |
| 390 | + return fmt.Sprintf("%d days", days) | |
| 391 | + } | |
| 392 | + return d.String() | |
| 393 | +} | |
| 394 | + | |
| 395 | +func formatCurrencyAmount(currency string, cents int64) string { | |
| 396 | + currency = strings.ToUpper(strings.TrimSpace(currency)) | |
| 397 | + sign := "" | |
| 398 | + if cents < 0 { | |
| 399 | + sign = "-" | |
| 400 | + cents = -cents | |
| 401 | + } | |
| 402 | + major := cents / 100 | |
| 403 | + minor := cents % 100 | |
| 404 | + if currency == "" { | |
| 405 | + currency = "USD" | |
| 406 | + } | |
| 407 | + return fmt.Sprintf("%s$%d.%02d %s", sign, major, minor, currency) | |
| 408 | +} | |
internal/web/handlers/orgs/billing_test.goadded@@ -0,0 +1,241 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package orgs_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "encoding/json" | |
| 8 | + "io" | |
| 9 | + "log/slog" | |
| 10 | + "net/http" | |
| 11 | + "net/http/httptest" | |
| 12 | + "net/url" | |
| 13 | + "strconv" | |
| 14 | + "strings" | |
| 15 | + "testing" | |
| 16 | + "testing/fstest" | |
| 17 | + "time" | |
| 18 | + | |
| 19 | + "github.com/go-chi/chi/v5" | |
| 20 | + "github.com/jackc/pgx/v5/pgxpool" | |
| 21 | + stripeapi "github.com/stripe/stripe-go/v85" | |
| 22 | + | |
| 23 | + orgbilling "github.com/tenseleyFlow/shithub/internal/billing" | |
| 24 | + billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc" | |
| 25 | + "github.com/tenseleyFlow/shithub/internal/billing/stripebilling" | |
| 26 | + "github.com/tenseleyFlow/shithub/internal/testing/dbtest" | |
| 27 | + orgsh "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs" | |
| 28 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" | |
| 29 | + "github.com/tenseleyFlow/shithub/internal/web/render" | |
| 30 | +) | |
| 31 | + | |
| 32 | +func TestOrgBillingCheckoutRedirectsToStripeAndCreatesCustomer(t *testing.T) { | |
| 33 | + t.Parallel() | |
| 34 | + ctx := context.Background() | |
| 35 | + pool := dbtest.NewTestDB(t) | |
| 36 | + ownerID := insertOrgAvatarUser(t, pool, "owner") | |
| 37 | + orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme") | |
| 38 | + fake := &fakeStripeRemote{ | |
| 39 | + createCustomerFn: func(_ context.Context, in stripebilling.CustomerInput) (stripebilling.Customer, error) { | |
| 40 | + if in.OrgID != orgID || in.OrgSlug != "acme" { | |
| 41 | + t.Fatalf("unexpected customer input: %+v", in) | |
| 42 | + } | |
| 43 | + return stripebilling.Customer{ID: "cus_test_checkout"}, nil | |
| 44 | + }, | |
| 45 | + createCheckoutFn: func(_ context.Context, in stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error) { | |
| 46 | + if in.CustomerID != "cus_test_checkout" { | |
| 47 | + t.Fatalf("checkout customer = %q", in.CustomerID) | |
| 48 | + } | |
| 49 | + if in.SeatCount != 1 { | |
| 50 | + t.Fatalf("checkout seats = %d, want 1", in.SeatCount) | |
| 51 | + } | |
| 52 | + if !strings.Contains(in.SuccessURL, "/organizations/acme/billing/success") { | |
| 53 | + t.Fatalf("success url = %q", in.SuccessURL) | |
| 54 | + } | |
| 55 | + if !strings.Contains(in.CancelURL, "/organizations/acme/billing/cancel") { | |
| 56 | + t.Fatalf("cancel url = %q", in.CancelURL) | |
| 57 | + } | |
| 58 | + return stripebilling.CheckoutSession{ID: "cs_test", URL: "https://checkout.stripe.test/session"}, nil | |
| 59 | + }, | |
| 60 | + } | |
| 61 | + mux := newOrgBillingMux(t, pool, ownerID, fake) | |
| 62 | + | |
| 63 | + resp := httptest.NewRecorder() | |
| 64 | + req := newOrgFormRequest(http.MethodPost, "/organizations/acme/billing/checkout", url.Values{}) | |
| 65 | + mux.ServeHTTP(resp, req) | |
| 66 | + if resp.Code != http.StatusSeeOther { | |
| 67 | + t.Fatalf("checkout status=%d body=%s", resp.Code, resp.Body.String()) | |
| 68 | + } | |
| 69 | + if got := resp.Header().Get("Location"); got != "https://checkout.stripe.test/session" { | |
| 70 | + t.Fatalf("checkout redirect=%q", got) | |
| 71 | + } | |
| 72 | + state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID) | |
| 73 | + if err != nil { | |
| 74 | + t.Fatalf("GetOrgBillingState: %v", err) | |
| 75 | + } | |
| 76 | + if !state.StripeCustomerID.Valid || state.StripeCustomerID.String != "cus_test_checkout" { | |
| 77 | + t.Fatalf("expected stripe customer saved, got %+v", state.StripeCustomerID) | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +func TestOrgBillingWebhookProcessesSubscriptionAndStaysIdempotent(t *testing.T) { | |
| 82 | + t.Parallel() | |
| 83 | + ctx := context.Background() | |
| 84 | + pool := dbtest.NewTestDB(t) | |
| 85 | + ownerID := insertOrgAvatarUser(t, pool, "owner") | |
| 86 | + orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme") | |
| 87 | + raw, err := json.Marshal(map[string]any{ | |
| 88 | + "id": "sub_test", | |
| 89 | + "customer": "cus_test_webhook", | |
| 90 | + "status": "active", | |
| 91 | + "cancel_at_period_end": false, | |
| 92 | + "trial_end": int64(0), | |
| 93 | + "canceled_at": int64(0), | |
| 94 | + "metadata": map[string]string{stripebilling.MetadataOrgID: strconv.FormatInt(orgID, 10)}, | |
| 95 | + "items": map[string]any{"data": []map[string]any{{ | |
| 96 | + "id": "si_test_webhook", | |
| 97 | + "current_period_start": time.Now().UTC().Add(-time.Hour).Unix(), | |
| 98 | + "current_period_end": time.Now().UTC().Add(30 * 24 * time.Hour).Unix(), | |
| 99 | + }}}, | |
| 100 | + }) | |
| 101 | + if err != nil { | |
| 102 | + t.Fatalf("marshal subscription raw: %v", err) | |
| 103 | + } | |
| 104 | + fake := &fakeStripeRemote{ | |
| 105 | + verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) { | |
| 106 | + return stripeapi.Event{ | |
| 107 | + ID: "evt_sub_active", | |
| 108 | + Type: stripeapi.EventType("customer.subscription.updated"), | |
| 109 | + APIVersion: "2024-06-20", | |
| 110 | + Data: &stripeapi.EventData{Raw: raw}, | |
| 111 | + }, nil | |
| 112 | + }, | |
| 113 | + } | |
| 114 | + mux := newOrgBillingMux(t, pool, ownerID, fake) | |
| 115 | + | |
| 116 | + req := httptest.NewRequest(http.MethodPost, "/stripe/webhook", strings.NewReader(`{"id":"evt_sub_active"}`)) | |
| 117 | + req.Header.Set("Stripe-Signature", "sig_test") | |
| 118 | + resp := httptest.NewRecorder() | |
| 119 | + mux.ServeHTTP(resp, req) | |
| 120 | + if resp.Code != http.StatusOK { | |
| 121 | + t.Fatalf("first webhook status=%d body=%s", resp.Code, resp.Body.String()) | |
| 122 | + } | |
| 123 | + state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID) | |
| 124 | + if err != nil { | |
| 125 | + t.Fatalf("GetOrgBillingState: %v", err) | |
| 126 | + } | |
| 127 | + if state.Plan != orgbilling.PlanTeam || state.SubscriptionStatus != orgbilling.SubscriptionStatusActive { | |
| 128 | + t.Fatalf("unexpected billing state: %+v", state) | |
| 129 | + } | |
| 130 | + if !state.StripeCustomerID.Valid || state.StripeCustomerID.String != "cus_test_webhook" { | |
| 131 | + t.Fatalf("expected customer id saved, got %+v", state.StripeCustomerID) | |
| 132 | + } | |
| 133 | + if !state.StripeSubscriptionID.Valid || state.StripeSubscriptionID.String != "sub_test" { | |
| 134 | + t.Fatalf("expected subscription id saved, got %+v", state.StripeSubscriptionID) | |
| 135 | + } | |
| 136 | + receipt, err := billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_sub_active") | |
| 137 | + if err != nil { | |
| 138 | + t.Fatalf("GetWebhookEventReceipt: %v", err) | |
| 139 | + } | |
| 140 | + if !receipt.ProcessedAt.Valid || receipt.ProcessingAttempts != 1 { | |
| 141 | + t.Fatalf("unexpected receipt after first processing: %+v", receipt) | |
| 142 | + } | |
| 143 | + | |
| 144 | + req = httptest.NewRequest(http.MethodPost, "/stripe/webhook", strings.NewReader(`{"id":"evt_sub_active"}`)) | |
| 145 | + req.Header.Set("Stripe-Signature", "sig_test") | |
| 146 | + resp = httptest.NewRecorder() | |
| 147 | + mux.ServeHTTP(resp, req) | |
| 148 | + if resp.Code != http.StatusOK { | |
| 149 | + t.Fatalf("duplicate webhook status=%d body=%s", resp.Code, resp.Body.String()) | |
| 150 | + } | |
| 151 | + receipt, err = billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_sub_active") | |
| 152 | + if err != nil { | |
| 153 | + t.Fatalf("GetWebhookEventReceipt duplicate: %v", err) | |
| 154 | + } | |
| 155 | + if receipt.ProcessingAttempts != 1 { | |
| 156 | + t.Fatalf("duplicate webhook should not reprocess receipt: %+v", receipt) | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +type fakeStripeRemote struct { | |
| 161 | + createCustomerFn func(context.Context, stripebilling.CustomerInput) (stripebilling.Customer, error) | |
| 162 | + createCheckoutFn func(context.Context, stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error) | |
| 163 | + createPortalFn func(context.Context, stripebilling.PortalInput) (stripebilling.PortalSession, error) | |
| 164 | + updateQuantityFn func(context.Context, stripebilling.SeatQuantityInput) error | |
| 165 | + verifyWebhookFn func([]byte, string) (stripeapi.Event, error) | |
| 166 | +} | |
| 167 | + | |
| 168 | +func (f *fakeStripeRemote) CreateCustomer(ctx context.Context, in stripebilling.CustomerInput) (stripebilling.Customer, error) { | |
| 169 | + if f.createCustomerFn == nil { | |
| 170 | + return stripebilling.Customer{}, nil | |
| 171 | + } | |
| 172 | + return f.createCustomerFn(ctx, in) | |
| 173 | +} | |
| 174 | + | |
| 175 | +func (f *fakeStripeRemote) CreateCheckoutSession(ctx context.Context, in stripebilling.CheckoutInput) (stripebilling.CheckoutSession, error) { | |
| 176 | + if f.createCheckoutFn == nil { | |
| 177 | + return stripebilling.CheckoutSession{}, nil | |
| 178 | + } | |
| 179 | + return f.createCheckoutFn(ctx, in) | |
| 180 | +} | |
| 181 | + | |
| 182 | +func (f *fakeStripeRemote) CreatePortalSession(ctx context.Context, in stripebilling.PortalInput) (stripebilling.PortalSession, error) { | |
| 183 | + if f.createPortalFn == nil { | |
| 184 | + return stripebilling.PortalSession{}, nil | |
| 185 | + } | |
| 186 | + return f.createPortalFn(ctx, in) | |
| 187 | +} | |
| 188 | + | |
| 189 | +func (f *fakeStripeRemote) UpdateSubscriptionItemQuantity(ctx context.Context, in stripebilling.SeatQuantityInput) error { | |
| 190 | + if f.updateQuantityFn == nil { | |
| 191 | + return nil | |
| 192 | + } | |
| 193 | + return f.updateQuantityFn(ctx, in) | |
| 194 | +} | |
| 195 | + | |
| 196 | +func (f *fakeStripeRemote) VerifyWebhook(payload []byte, signatureHeader string) (stripeapi.Event, error) { | |
| 197 | + if f.verifyWebhookFn == nil { | |
| 198 | + return stripeapi.Event{}, nil | |
| 199 | + } | |
| 200 | + return f.verifyWebhookFn(payload, signatureHeader) | |
| 201 | +} | |
| 202 | + | |
| 203 | +func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote stripebilling.Remote) *chi.Mux { | |
| 204 | + t.Helper() | |
| 205 | + tmplFS := fstest.MapFS{ | |
| 206 | + "_layout.html": {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)}, | |
| 207 | + "orgs/settings_billing.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Notice }}NOTICE={{ . }}{{ end }}{{ range .Invoices }}INVOICE={{ .Number }};{{ end }}{{ end }}`)}, | |
| 208 | + "errors/403.html": {Data: []byte(`{{ define "page" }}403{{ end }}`)}, | |
| 209 | + "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)}, | |
| 210 | + "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)}, | |
| 211 | + } | |
| 212 | + rr, err := render.New(tmplFS, render.Options{}) | |
| 213 | + if err != nil { | |
| 214 | + t.Fatalf("render.New: %v", err) | |
| 215 | + } | |
| 216 | + h, err := orgsh.New(orgsh.Deps{ | |
| 217 | + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 218 | + Render: rr, | |
| 219 | + Pool: pool, | |
| 220 | + BaseURL: "https://shithub.example", | |
| 221 | + BillingEnabled: true, | |
| 222 | + BillingGracePeriod: 14 * 24 * time.Hour, | |
| 223 | + Stripe: remote, | |
| 224 | + StripeSuccessURL: "https://shithub.example/organizations/{org}/billing/success", | |
| 225 | + StripeCancelURL: "https://shithub.example/organizations/{org}/billing/cancel", | |
| 226 | + StripePortalReturnURL: "https://shithub.example/organizations/{org}/settings/billing", | |
| 227 | + }) | |
| 228 | + if err != nil { | |
| 229 | + t.Fatalf("orgsh.New: %v", err) | |
| 230 | + } | |
| 231 | + mux := chi.NewRouter() | |
| 232 | + mux.Use(func(next http.Handler) http.Handler { | |
| 233 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 234 | + viewer := middleware.CurrentUser{ID: ownerID, Username: "owner"} | |
| 235 | + next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer))) | |
| 236 | + }) | |
| 237 | + }) | |
| 238 | + h.MountCreate(mux) | |
| 239 | + h.MountBillingWebhook(mux) | |
| 240 | + return mux | |
| 241 | +} | |
internal/web/handlers/orgs/billing_webhook.goadded@@ -0,0 +1,361 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package orgs | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "encoding/json" | |
| 8 | + "errors" | |
| 9 | + "fmt" | |
| 10 | + "io" | |
| 11 | + "net/http" | |
| 12 | + "strconv" | |
| 13 | + "strings" | |
| 14 | + "time" | |
| 15 | + | |
| 16 | + "github.com/jackc/pgx/v5" | |
| 17 | + stripeapi "github.com/stripe/stripe-go/v85" | |
| 18 | + | |
| 19 | + orgbilling "github.com/tenseleyFlow/shithub/internal/billing" | |
| 20 | + "github.com/tenseleyFlow/shithub/internal/billing/stripebilling" | |
| 21 | +) | |
| 22 | + | |
| 23 | +const stripeWebhookBodyLimit = 1 << 20 | |
| 24 | + | |
| 25 | +func (h *Handlers) billingWebhook(w http.ResponseWriter, r *http.Request) { | |
| 26 | + if !h.billingConfigured() { | |
| 27 | + http.NotFound(w, r) | |
| 28 | + return | |
| 29 | + } | |
| 30 | + r.Body = http.MaxBytesReader(w, r.Body, stripeWebhookBodyLimit) | |
| 31 | + payload, err := io.ReadAll(r.Body) | |
| 32 | + if err != nil { | |
| 33 | + http.Error(w, "invalid webhook body", http.StatusBadRequest) | |
| 34 | + return | |
| 35 | + } | |
| 36 | + event, err := h.d.Stripe.VerifyWebhook(payload, r.Header.Get("Stripe-Signature")) | |
| 37 | + if err != nil { | |
| 38 | + http.Error(w, "invalid stripe signature", http.StatusBadRequest) | |
| 39 | + return | |
| 40 | + } | |
| 41 | + receipt, created, err := orgbilling.RecordWebhookEvent(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, orgbilling.WebhookEvent{ | |
| 42 | + ProviderEventID: event.ID, | |
| 43 | + EventType: string(event.Type), | |
| 44 | + APIVersion: event.APIVersion, | |
| 45 | + Payload: payload, | |
| 46 | + }) | |
| 47 | + if err != nil { | |
| 48 | + h.d.Logger.ErrorContext(r.Context(), "org billing: record webhook receipt", "event_id", event.ID, "event_type", event.Type, "error", err) | |
| 49 | + http.Error(w, "could not record webhook receipt", http.StatusInternalServerError) | |
| 50 | + return | |
| 51 | + } | |
| 52 | + if !created && receipt.ProcessedAt.Valid { | |
| 53 | + w.WriteHeader(http.StatusOK) | |
| 54 | + _, _ = w.Write([]byte("ok")) | |
| 55 | + return | |
| 56 | + } | |
| 57 | + if err := h.processStripeWebhook(r.Context(), event); err != nil { | |
| 58 | + h.d.Logger.ErrorContext(r.Context(), "org billing: process webhook", "event_id", event.ID, "event_type", event.Type, "error", err) | |
| 59 | + if _, markErr := orgbilling.MarkWebhookEventFailed(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, event.ID, err.Error()); markErr != nil { | |
| 60 | + h.d.Logger.ErrorContext(r.Context(), "org billing: mark webhook failed", "event_id", event.ID, "error", markErr) | |
| 61 | + } | |
| 62 | + http.Error(w, "webhook processing failed", http.StatusInternalServerError) | |
| 63 | + return | |
| 64 | + } | |
| 65 | + if _, err := orgbilling.MarkWebhookEventProcessed(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, event.ID); err != nil { | |
| 66 | + h.d.Logger.ErrorContext(r.Context(), "org billing: mark webhook processed", "event_id", event.ID, "error", err) | |
| 67 | + http.Error(w, "could not finalize webhook receipt", http.StatusInternalServerError) | |
| 68 | + return | |
| 69 | + } | |
| 70 | + w.WriteHeader(http.StatusOK) | |
| 71 | + _, _ = w.Write([]byte("ok")) | |
| 72 | +} | |
| 73 | + | |
| 74 | +func (h *Handlers) processStripeWebhook(ctx context.Context, event stripeapi.Event) error { | |
| 75 | + switch string(event.Type) { | |
| 76 | + case "checkout.session.completed": | |
| 77 | + return h.applyStripeCheckoutCompleted(ctx, event) | |
| 78 | + case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted": | |
| 79 | + return h.applyStripeSubscriptionEvent(ctx, event) | |
| 80 | + case "invoice.payment_succeeded", "invoice.payment_failed", "invoice.voided", "invoice.marked_uncollectible": | |
| 81 | + return h.applyStripeInvoiceEvent(ctx, event) | |
| 82 | + default: | |
| 83 | + return nil | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +func (h *Handlers) applyStripeCheckoutCompleted(ctx context.Context, event stripeapi.Event) error { | |
| 88 | + var session stripeapi.CheckoutSession | |
| 89 | + if err := unmarshalStripeEventObject(event, &session); err != nil { | |
| 90 | + return err | |
| 91 | + } | |
| 92 | + orgID := stripeOrgIDFromMetadata(session.Metadata) | |
| 93 | + if orgID == 0 { | |
| 94 | + if id, err := strconv.ParseInt(strings.TrimSpace(session.ClientReferenceID), 10, 64); err == nil && id > 0 { | |
| 95 | + orgID = id | |
| 96 | + } | |
| 97 | + } | |
| 98 | + if orgID == 0 { | |
| 99 | + return errors.New("stripe checkout.session.completed missing shithub org metadata") | |
| 100 | + } | |
| 101 | + customerID := stripeCustomerID(session.Customer) | |
| 102 | + if customerID == "" { | |
| 103 | + return errors.New("stripe checkout.session.completed missing customer") | |
| 104 | + } | |
| 105 | + _, err := orgbilling.SetStripeCustomer(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID, customerID) | |
| 106 | + return err | |
| 107 | +} | |
| 108 | + | |
| 109 | +func (h *Handlers) applyStripeSubscriptionEvent(ctx context.Context, event stripeapi.Event) error { | |
| 110 | + var sub stripeapi.Subscription | |
| 111 | + if err := unmarshalStripeEventObject(event, &sub); err != nil { | |
| 112 | + return err | |
| 113 | + } | |
| 114 | + orgID, err := h.resolveOrgIDFromSubscription(ctx, &sub) | |
| 115 | + if err != nil { | |
| 116 | + return err | |
| 117 | + } | |
| 118 | + customerID := stripeCustomerID(sub.Customer) | |
| 119 | + if customerID != "" { | |
| 120 | + if _, err := orgbilling.SetStripeCustomer(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID, customerID); err != nil { | |
| 121 | + return err | |
| 122 | + } | |
| 123 | + } | |
| 124 | + status, err := stripeSubscriptionStatus(sub.Status) | |
| 125 | + if err != nil { | |
| 126 | + return err | |
| 127 | + } | |
| 128 | + if status == orgbilling.SubscriptionStatusCanceled || string(event.Type) == "customer.subscription.deleted" { | |
| 129 | + _, err := orgbilling.MarkCanceled(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID, event.ID) | |
| 130 | + return err | |
| 131 | + } | |
| 132 | + itemID := stripeSubscriptionItemID(sub.Items) | |
| 133 | + periodStart, periodEnd := stripeSubscriptionPeriod(sub.Items) | |
| 134 | + _, err = orgbilling.ApplySubscriptionSnapshot(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgbilling.SubscriptionSnapshot{ | |
| 135 | + OrgID: orgID, | |
| 136 | + Plan: orgbilling.PlanTeam, | |
| 137 | + Status: status, | |
| 138 | + StripeSubscriptionID: strings.TrimSpace(sub.ID), | |
| 139 | + StripeSubscriptionItemID: itemID, | |
| 140 | + CurrentPeriodStart: periodStart, | |
| 141 | + CurrentPeriodEnd: periodEnd, | |
| 142 | + CancelAtPeriodEnd: sub.CancelAtPeriodEnd, | |
| 143 | + TrialEnd: unixTime(sub.TrialEnd), | |
| 144 | + CanceledAt: unixTime(sub.CanceledAt), | |
| 145 | + LastWebhookEventID: event.ID, | |
| 146 | + }) | |
| 147 | + return err | |
| 148 | +} | |
| 149 | + | |
| 150 | +func (h *Handlers) applyStripeInvoiceEvent(ctx context.Context, event stripeapi.Event) error { | |
| 151 | + var inv stripeapi.Invoice | |
| 152 | + if err := unmarshalStripeEventObject(event, &inv); err != nil { | |
| 153 | + return err | |
| 154 | + } | |
| 155 | + orgID, state, err := h.resolveOrgStateFromInvoice(ctx, &inv) | |
| 156 | + if err != nil { | |
| 157 | + return err | |
| 158 | + } | |
| 159 | + status, err := stripeInvoiceStatus(inv.Status) | |
| 160 | + if err != nil { | |
| 161 | + return err | |
| 162 | + } | |
| 163 | + if _, err := orgbilling.UpsertInvoice(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgbilling.InvoiceSnapshot{ | |
| 164 | + OrgID: orgID, | |
| 165 | + StripeInvoiceID: strings.TrimSpace(inv.ID), | |
| 166 | + StripeCustomerID: stripeCustomerID(inv.Customer), | |
| 167 | + StripeSubscriptionID: stripeInvoiceSubscriptionID(&inv), | |
| 168 | + Status: status, | |
| 169 | + Number: strings.TrimSpace(inv.Number), | |
| 170 | + Currency: strings.ToLower(string(inv.Currency)), | |
| 171 | + AmountDueCents: inv.AmountDue, | |
| 172 | + AmountPaidCents: inv.AmountPaid, | |
| 173 | + AmountRemainingCents: inv.AmountRemaining, | |
| 174 | + HostedInvoiceURL: strings.TrimSpace(inv.HostedInvoiceURL), | |
| 175 | + InvoicePDFURL: strings.TrimSpace(inv.InvoicePDF), | |
| 176 | + PeriodStart: unixTime(inv.PeriodStart), | |
| 177 | + PeriodEnd: unixTime(inv.PeriodEnd), | |
| 178 | + DueAt: unixTime(inv.DueDate), | |
| 179 | + PaidAt: unixTime(stripeInvoicePaidAt(inv.StatusTransitions)), | |
| 180 | + VoidedAt: unixTime(stripeInvoiceVoidedAt(inv.StatusTransitions)), | |
| 181 | + }); err != nil { | |
| 182 | + return err | |
| 183 | + } | |
| 184 | + switch string(event.Type) { | |
| 185 | + case "invoice.payment_failed": | |
| 186 | + graceUntil := time.Now().UTC().Add(h.d.BillingGracePeriod) | |
| 187 | + _, err := orgbilling.MarkPastDue(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID, graceUntil, event.ID) | |
| 188 | + return err | |
| 189 | + case "invoice.payment_succeeded": | |
| 190 | + if state.SubscriptionStatus != orgbilling.SubscriptionStatusCanceled { | |
| 191 | + _, err := orgbilling.ClearBillingLock(ctx, orgbilling.Deps{Pool: h.d.Pool}, orgID) | |
| 192 | + return err | |
| 193 | + } | |
| 194 | + } | |
| 195 | + return nil | |
| 196 | +} | |
| 197 | + | |
| 198 | +func (h *Handlers) resolveOrgIDFromSubscription(ctx context.Context, sub *stripeapi.Subscription) (int64, error) { | |
| 199 | + if orgID := stripeOrgIDFromMetadata(sub.Metadata); orgID != 0 { | |
| 200 | + return orgID, nil | |
| 201 | + } | |
| 202 | + if customerID := stripeCustomerID(sub.Customer); customerID != "" { | |
| 203 | + state, err := orgbilling.GetOrgBillingStateByStripeCustomer(ctx, orgbilling.Deps{Pool: h.d.Pool}, customerID) | |
| 204 | + if err == nil { | |
| 205 | + return state.OrgID, nil | |
| 206 | + } | |
| 207 | + if !errors.Is(err, pgx.ErrNoRows) { | |
| 208 | + return 0, err | |
| 209 | + } | |
| 210 | + } | |
| 211 | + if subID := strings.TrimSpace(sub.ID); subID != "" { | |
| 212 | + state, err := orgbilling.GetOrgBillingStateByStripeSubscription(ctx, orgbilling.Deps{Pool: h.d.Pool}, subID) | |
| 213 | + if err == nil { | |
| 214 | + return state.OrgID, nil | |
| 215 | + } | |
| 216 | + if !errors.Is(err, pgx.ErrNoRows) { | |
| 217 | + return 0, err | |
| 218 | + } | |
| 219 | + } | |
| 220 | + return 0, errors.New("stripe subscription does not map to a shithub organization") | |
| 221 | +} | |
| 222 | + | |
| 223 | +func (h *Handlers) resolveOrgStateFromInvoice(ctx context.Context, inv *stripeapi.Invoice) (int64, orgbilling.State, error) { | |
| 224 | + if customerID := stripeCustomerID(inv.Customer); customerID != "" { | |
| 225 | + state, err := orgbilling.GetOrgBillingStateByStripeCustomer(ctx, orgbilling.Deps{Pool: h.d.Pool}, customerID) | |
| 226 | + if err == nil { | |
| 227 | + return state.OrgID, state, nil | |
| 228 | + } | |
| 229 | + if !errors.Is(err, pgx.ErrNoRows) { | |
| 230 | + return 0, orgbilling.State{}, err | |
| 231 | + } | |
| 232 | + } | |
| 233 | + if subID := stripeInvoiceSubscriptionID(inv); subID != "" { | |
| 234 | + state, err := orgbilling.GetOrgBillingStateByStripeSubscription(ctx, orgbilling.Deps{Pool: h.d.Pool}, subID) | |
| 235 | + if err == nil { | |
| 236 | + return state.OrgID, state, nil | |
| 237 | + } | |
| 238 | + if !errors.Is(err, pgx.ErrNoRows) { | |
| 239 | + return 0, orgbilling.State{}, err | |
| 240 | + } | |
| 241 | + } | |
| 242 | + return 0, orgbilling.State{}, errors.New("stripe invoice does not map to a shithub organization") | |
| 243 | +} | |
| 244 | + | |
| 245 | +func stripeOrgIDFromMetadata(metadata map[string]string) int64 { | |
| 246 | + raw := strings.TrimSpace(metadata[stripebilling.MetadataOrgID]) | |
| 247 | + if raw == "" { | |
| 248 | + return 0 | |
| 249 | + } | |
| 250 | + n, err := strconv.ParseInt(raw, 10, 64) | |
| 251 | + if err != nil || n <= 0 { | |
| 252 | + return 0 | |
| 253 | + } | |
| 254 | + return n | |
| 255 | +} | |
| 256 | + | |
| 257 | +func stripeCustomerID(customer *stripeapi.Customer) string { | |
| 258 | + if customer == nil { | |
| 259 | + return "" | |
| 260 | + } | |
| 261 | + return strings.TrimSpace(customer.ID) | |
| 262 | +} | |
| 263 | + | |
| 264 | +func stripeSubscriptionID(sub *stripeapi.Subscription) string { | |
| 265 | + if sub == nil { | |
| 266 | + return "" | |
| 267 | + } | |
| 268 | + return strings.TrimSpace(sub.ID) | |
| 269 | +} | |
| 270 | + | |
| 271 | +func stripeSubscriptionItemID(items *stripeapi.SubscriptionItemList) string { | |
| 272 | + if items == nil || len(items.Data) == 0 || items.Data[0] == nil { | |
| 273 | + return "" | |
| 274 | + } | |
| 275 | + return strings.TrimSpace(items.Data[0].ID) | |
| 276 | +} | |
| 277 | + | |
| 278 | +func stripeSubscriptionPeriod(items *stripeapi.SubscriptionItemList) (time.Time, time.Time) { | |
| 279 | + if items == nil || len(items.Data) == 0 || items.Data[0] == nil { | |
| 280 | + return time.Time{}, time.Time{} | |
| 281 | + } | |
| 282 | + return unixTime(items.Data[0].CurrentPeriodStart), unixTime(items.Data[0].CurrentPeriodEnd) | |
| 283 | +} | |
| 284 | + | |
| 285 | +func stripeInvoiceSubscriptionID(inv *stripeapi.Invoice) string { | |
| 286 | + if inv == nil || inv.Parent == nil || inv.Parent.SubscriptionDetails == nil || inv.Parent.SubscriptionDetails.Subscription == nil { | |
| 287 | + return "" | |
| 288 | + } | |
| 289 | + return strings.TrimSpace(inv.Parent.SubscriptionDetails.Subscription.ID) | |
| 290 | +} | |
| 291 | + | |
| 292 | +func stripeSubscriptionStatus(status stripeapi.SubscriptionStatus) (orgbilling.SubscriptionStatus, error) { | |
| 293 | + switch string(status) { | |
| 294 | + case "incomplete": | |
| 295 | + return orgbilling.SubscriptionStatusIncomplete, nil | |
| 296 | + case "trialing": | |
| 297 | + return orgbilling.SubscriptionStatusTrialing, nil | |
| 298 | + case "active": | |
| 299 | + return orgbilling.SubscriptionStatusActive, nil | |
| 300 | + case "past_due": | |
| 301 | + return orgbilling.SubscriptionStatusPastDue, nil | |
| 302 | + case "canceled": | |
| 303 | + return orgbilling.SubscriptionStatusCanceled, nil | |
| 304 | + case "unpaid": | |
| 305 | + return orgbilling.SubscriptionStatusUnpaid, nil | |
| 306 | + case "paused": | |
| 307 | + return orgbilling.SubscriptionStatusPaused, nil | |
| 308 | + case "incomplete_expired": | |
| 309 | + return orgbilling.SubscriptionStatusCanceled, nil | |
| 310 | + default: | |
| 311 | + return "", fmt.Errorf("unsupported stripe subscription status %q", status) | |
| 312 | + } | |
| 313 | +} | |
| 314 | + | |
| 315 | +func stripeInvoiceStatus(status stripeapi.InvoiceStatus) (orgbilling.InvoiceStatus, error) { | |
| 316 | + switch string(status) { | |
| 317 | + case "draft": | |
| 318 | + return orgbilling.InvoiceStatusDraft, nil | |
| 319 | + case "open": | |
| 320 | + return orgbilling.InvoiceStatusOpen, nil | |
| 321 | + case "paid": | |
| 322 | + return orgbilling.InvoiceStatusPaid, nil | |
| 323 | + case "void": | |
| 324 | + return orgbilling.InvoiceStatusVoid, nil | |
| 325 | + case "uncollectible": | |
| 326 | + return orgbilling.InvoiceStatusUncollectible, nil | |
| 327 | + default: | |
| 328 | + return "", fmt.Errorf("unsupported stripe invoice status %q", status) | |
| 329 | + } | |
| 330 | +} | |
| 331 | + | |
| 332 | +func stripeInvoicePaidAt(transitions *stripeapi.InvoiceStatusTransitions) int64 { | |
| 333 | + if transitions == nil { | |
| 334 | + return 0 | |
| 335 | + } | |
| 336 | + return transitions.PaidAt | |
| 337 | +} | |
| 338 | + | |
| 339 | +func stripeInvoiceVoidedAt(transitions *stripeapi.InvoiceStatusTransitions) int64 { | |
| 340 | + if transitions == nil { | |
| 341 | + return 0 | |
| 342 | + } | |
| 343 | + return transitions.VoidedAt | |
| 344 | +} | |
| 345 | + | |
| 346 | +func unixTime(ts int64) time.Time { | |
| 347 | + if ts <= 0 { | |
| 348 | + return time.Time{} | |
| 349 | + } | |
| 350 | + return time.Unix(ts, 0).UTC() | |
| 351 | +} | |
| 352 | + | |
| 353 | +func unmarshalStripeEventObject[T any](event stripeapi.Event, out *T) error { | |
| 354 | + if event.Data == nil || len(event.Data.Raw) == 0 { | |
| 355 | + return errors.New("stripe webhook missing event data") | |
| 356 | + } | |
| 357 | + if err := json.Unmarshal(event.Data.Raw, out); err != nil { | |
| 358 | + return fmt.Errorf("decode stripe %s event: %w", event.Type, err) | |
| 359 | + } | |
| 360 | + return nil | |
| 361 | +} | |
internal/web/handlers/orgs/imports.gomodified@@ -118,6 +118,8 @@ func (h *Handlers) renderSettingsImport(w http.ResponseWriter, r *http.Request, | ||
| 118 | 118 | "Org": org, |
| 119 | 119 | "AvatarURL": "/avatars/" + url.PathEscape(org.Slug), |
| 120 | 120 | "ActiveOrgNav": "settings", |
| 121 | + "OrgSettingsActive": "import", | |
| 122 | + "BillingEnabled": h.d.BillingEnabled, | |
| 121 | 123 | "Form": form, |
| 122 | 124 | "Error": errMsg, |
| 123 | 125 | "Notice": notice, |
internal/web/handlers/orgs/orgs.gomodified@@ -32,6 +32,7 @@ import ( | ||
| 32 | 32 | "net/url" |
| 33 | 33 | "strconv" |
| 34 | 34 | "strings" |
| 35 | + "time" | |
| 35 | 36 | |
| 36 | 37 | "github.com/go-chi/chi/v5" |
| 37 | 38 | "github.com/jackc/pgx/v5/pgxpool" |
@@ -39,6 +40,7 @@ import ( | ||
| 39 | 40 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 40 | 41 | authemail "github.com/tenseleyFlow/shithub/internal/auth/email" |
| 41 | 42 | "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 43 | + "github.com/tenseleyFlow/shithub/internal/billing/stripebilling" | |
| 42 | 44 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 43 | 45 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 44 | 46 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
@@ -58,6 +60,12 @@ type Deps struct { | ||
| 58 | 60 | ObjectStore storage.ObjectStore |
| 59 | 61 | SecretBox *secretbox.Box |
| 60 | 62 | Audit *audit.Recorder |
| 63 | + BillingEnabled bool | |
| 64 | + BillingGracePeriod time.Duration | |
| 65 | + Stripe stripebilling.Remote | |
| 66 | + StripeSuccessURL string | |
| 67 | + StripeCancelURL string | |
| 68 | + StripePortalReturnURL string | |
| 61 | 69 | } |
| 62 | 70 | |
| 63 | 71 | // Handlers groups the org surface handlers. |
@@ -101,6 +109,13 @@ func (h *Handlers) MountCreate(r chi.Router) { | ||
| 101 | 109 | r.Get("/organizations/{org}/settings/variables/actions", h.settingsActionsVariables) |
| 102 | 110 | r.Post("/organizations/{org}/settings/variables/actions", h.settingsActionsVariableSet) |
| 103 | 111 | r.Post("/organizations/{org}/settings/variables/actions/{name}/delete", h.settingsActionsVariableDelete) |
| 112 | + if h.billingConfigured() { | |
| 113 | + r.Get("/organizations/{org}/settings/billing", h.settingsBilling) | |
| 114 | + r.Post("/organizations/{org}/billing/checkout", h.billingCheckout) | |
| 115 | + r.Post("/organizations/{org}/billing/portal", h.billingPortal) | |
| 116 | + r.Get("/organizations/{org}/billing/success", h.billingSuccess) | |
| 117 | + r.Get("/organizations/{org}/billing/cancel", h.billingCancel) | |
| 118 | + } | |
| 104 | 119 | } |
| 105 | 120 | |
| 106 | 121 | // MountOrgRoutes registers the per-org surface under /{org}/people |
@@ -126,6 +141,13 @@ func (h *Handlers) MountInvitations(r chi.Router) { | ||
| 126 | 141 | r.Post("/invitations/{token}/decline", h.invitationDecline) |
| 127 | 142 | } |
| 128 | 143 | |
| 144 | +func (h *Handlers) MountBillingWebhook(r chi.Router) { | |
| 145 | + if !h.billingConfigured() { | |
| 146 | + return | |
| 147 | + } | |
| 148 | + r.Post("/stripe/webhook", h.billingWebhook) | |
| 149 | +} | |
| 150 | + | |
| 129 | 151 | // ─── helpers ─────────────────────────────────────────────────────── |
| 130 | 152 | |
| 131 | 153 | func (h *Handlers) deps() orgs.Deps { |
@@ -139,6 +161,10 @@ func (h *Handlers) deps() orgs.Deps { | ||
| 139 | 161 | } |
| 140 | 162 | } |
| 141 | 163 | |
| 164 | +func (h *Handlers) billingConfigured() bool { | |
| 165 | + return h.d.BillingEnabled && h.d.Stripe != nil | |
| 166 | +} | |
| 167 | + | |
| 142 | 168 | // orgFromSlug resolves the org from a {org} URL param, with an |
| 143 | 169 | // existence-leak-safe 404 path. |
| 144 | 170 | func (h *Handlers) orgFromSlug(w http.ResponseWriter, r *http.Request) (orgsdb.Org, bool) { |
internal/web/handlers/orgs/settings_profile.gomodified@@ -158,6 +158,8 @@ func (h *Handlers) renderSettingsProfileWithForm( | ||
| 158 | 158 | "Form": form, |
| 159 | 159 | "AvatarURL": "/avatars/" + url.PathEscape(org.Slug), |
| 160 | 160 | "ActiveOrgNav": "settings", |
| 161 | + "OrgSettingsActive": "profile", | |
| 162 | + "BillingEnabled": h.d.BillingEnabled, | |
| 161 | 163 | "AvatarUploadEnabled": h.d.ObjectStore != nil, |
| 162 | 164 | "HasAvatar": org.AvatarObjectKey.Valid && org.AvatarObjectKey.String != "", |
| 163 | 165 | "Error": errMsg, |
internal/web/orgs_wiring.gomodified@@ -15,6 +15,7 @@ import ( | ||
| 15 | 15 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 16 | 16 | "github.com/tenseleyFlow/shithub/internal/auth/email" |
| 17 | 17 | "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 18 | + "github.com/tenseleyFlow/shithub/internal/billing/stripebilling" | |
| 18 | 19 | "github.com/tenseleyFlow/shithub/internal/infra/config" |
| 19 | 20 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 20 | 21 | orgshandlers "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs" |
@@ -34,6 +35,19 @@ func buildOrgHandlers( | ||
| 34 | 35 | if err != nil { |
| 35 | 36 | return nil, err |
| 36 | 37 | } |
| 38 | + var stripeRemote stripebilling.Remote | |
| 39 | + if cfg.Billing.Enabled { | |
| 40 | + remote, err := stripebilling.New(stripebilling.Config{ | |
| 41 | + SecretKey: cfg.Billing.Stripe.SecretKey, | |
| 42 | + WebhookSecret: cfg.Billing.Stripe.WebhookSecret, | |
| 43 | + TeamPriceID: cfg.Billing.Stripe.TeamPriceID, | |
| 44 | + AutomaticTax: cfg.Billing.Stripe.AutomaticTax, | |
| 45 | + }) | |
| 46 | + if err != nil { | |
| 47 | + return nil, err | |
| 48 | + } | |
| 49 | + stripeRemote = remote | |
| 50 | + } | |
| 37 | 51 | sender, _ := pickOrgsEmailSender(cfg) |
| 38 | 52 | var box *secretbox.Box |
| 39 | 53 | if cfg.Auth.TOTPKeyB64 != "" { |
@@ -56,6 +70,12 @@ func buildOrgHandlers( | ||
| 56 | 70 | ObjectStore: objectStore, |
| 57 | 71 | SecretBox: box, |
| 58 | 72 | Audit: audit.NewRecorder(), |
| 73 | + BillingEnabled: cfg.Billing.Enabled, | |
| 74 | + BillingGracePeriod: cfg.Billing.GracePeriod, | |
| 75 | + Stripe: stripeRemote, | |
| 76 | + StripeSuccessURL: cfg.Billing.Stripe.SuccessURL, | |
| 77 | + StripeCancelURL: cfg.Billing.Stripe.CancelURL, | |
| 78 | + StripePortalReturnURL: cfg.Billing.Stripe.PortalReturnURL, | |
| 59 | 79 | }) |
| 60 | 80 | } |
| 61 | 81 | |
internal/web/server.gomodified@@ -285,6 +285,9 @@ func Run(ctx context.Context, opts Options) error { | ||
| 285 | 285 | if err != nil { |
| 286 | 286 | return fmt.Errorf("org handlers: %w", err) |
| 287 | 287 | } |
| 288 | + if cfg.Billing.Enabled { | |
| 289 | + deps.BillingWebhookMounter = orgH.MountBillingWebhook | |
| 290 | + } | |
| 288 | 291 | deps.OrgCreateMounter = func(r chi.Router) { |
| 289 | 292 | r.Group(func(r chi.Router) { |
| 290 | 293 | r.Use(middleware.RequireUser) |
internal/web/static/css/shithub.cssmodified@@ -3967,6 +3967,84 @@ button.shithub-contrib-setting-item:hover { | ||
| 3967 | 3967 | margin: 0.15rem 0 0; |
| 3968 | 3968 | color: var(--fg-muted); |
| 3969 | 3969 | } |
| 3970 | +.shithub-org-billing-summary { | |
| 3971 | + display: grid; | |
| 3972 | + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| 3973 | + gap: 1rem; | |
| 3974 | +} | |
| 3975 | +.shithub-org-billing-card { | |
| 3976 | + min-height: 100%; | |
| 3977 | +} | |
| 3978 | +.shithub-org-billing-card .Box-body { | |
| 3979 | + display: grid; | |
| 3980 | + gap: 0.35rem; | |
| 3981 | +} | |
| 3982 | +.shithub-org-billing-card-value { | |
| 3983 | + margin: 0; | |
| 3984 | + font-size: 1.5rem; | |
| 3985 | + font-weight: 600; | |
| 3986 | + line-height: 1.2; | |
| 3987 | +} | |
| 3988 | +.shithub-org-billing-card-detail { | |
| 3989 | + margin: 0; | |
| 3990 | + color: var(--fg-muted); | |
| 3991 | + font-size: 0.875rem; | |
| 3992 | +} | |
| 3993 | +.shithub-org-billing-table { | |
| 3994 | + width: 100%; | |
| 3995 | + border-collapse: collapse; | |
| 3996 | +} | |
| 3997 | +.shithub-org-billing-table th, | |
| 3998 | +.shithub-org-billing-table td { | |
| 3999 | + padding: 0.75rem 1rem; | |
| 4000 | + border-top: 1px solid var(--border-muted, var(--border-default)); | |
| 4001 | + text-align: left; | |
| 4002 | + vertical-align: top; | |
| 4003 | +} | |
| 4004 | +.shithub-org-billing-table thead th { | |
| 4005 | + border-top: 0; | |
| 4006 | + color: var(--fg-muted); | |
| 4007 | + font-size: 0.75rem; | |
| 4008 | + font-weight: 600; | |
| 4009 | + text-transform: uppercase; | |
| 4010 | +} | |
| 4011 | +.shithub-org-billing-links { | |
| 4012 | + white-space: nowrap; | |
| 4013 | +} | |
| 4014 | +.shithub-org-billing-links a + a { | |
| 4015 | + margin-left: 0.65rem; | |
| 4016 | +} | |
| 4017 | +.shithub-org-billing-status { | |
| 4018 | + display: inline-flex; | |
| 4019 | + align-items: center; | |
| 4020 | + min-height: 1.5rem; | |
| 4021 | + padding: 0 0.5rem; | |
| 4022 | + border-radius: 999px; | |
| 4023 | + font-size: 0.75rem; | |
| 4024 | + font-weight: 600; | |
| 4025 | + text-transform: uppercase; | |
| 4026 | + letter-spacing: 0; | |
| 4027 | + background: var(--canvas-subtle); | |
| 4028 | + color: var(--fg-default); | |
| 4029 | +} | |
| 4030 | +.shithub-org-billing-status.is-paid, | |
| 4031 | +.shithub-org-billing-status.is-active { | |
| 4032 | + background: rgba(35, 134, 54, 0.18); | |
| 4033 | + color: var(--success-fg); | |
| 4034 | +} | |
| 4035 | +.shithub-org-billing-status.is-open, | |
| 4036 | +.shithub-org-billing-status.is-draft { | |
| 4037 | + background: rgba(9, 105, 218, 0.16); | |
| 4038 | + color: var(--accent-fg); | |
| 4039 | +} | |
| 4040 | +.shithub-org-billing-status.is-uncollectible, | |
| 4041 | +.shithub-org-billing-status.is-void, | |
| 4042 | +.shithub-org-billing-status.is-past-due, | |
| 4043 | +.shithub-org-billing-status.is-unpaid, | |
| 4044 | +.shithub-org-billing-status.is-canceled { | |
| 4045 | + background: rgba(218, 54, 51, 0.16); | |
| 4046 | + color: var(--danger-fg); | |
| 4047 | +} | |
| 3970 | 4048 | .shithub-org-import-create { |
| 3971 | 4049 | display: grid; |
| 3972 | 4050 | gap: 0.75rem; |
internal/web/templates/orgs/settings_billing.htmladded@@ -0,0 +1,124 @@ | ||
| 1 | +{{ define "page" -}} | |
| 2 | +<section class="shithub-org-settings-page"> | |
| 3 | + <header class="shithub-org-pagehead shithub-org-settings-pagehead"> | |
| 4 | + <div class="shithub-org-pagehead-inner"> | |
| 5 | + <a class="shithub-org-pagehead-title" href="/{{ .Org.Slug }}"> | |
| 6 | + <img src="{{ .AvatarURL }}" alt="" width="30" height="30"> | |
| 7 | + <span>{{ if .Org.DisplayName }}{{ .Org.DisplayName }}{{ else }}{{ .Org.Slug }}{{ end }}</span> | |
| 8 | + </a> | |
| 9 | + </div> | |
| 10 | + </header> | |
| 11 | + | |
| 12 | + <div class="shithub-org-settings-layout"> | |
| 13 | + {{ template "org-settings-nav" . }} | |
| 14 | + | |
| 15 | + <div class="shithub-org-settings-main"> | |
| 16 | + {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }} | |
| 17 | + {{ with .Notice }}<p class="shithub-flash shithub-flash-success" role="status">{{ . }}</p>{{ end }} | |
| 18 | + | |
| 19 | + <div class="Subhead"> | |
| 20 | + <h2 class="Subhead-heading Subhead-heading--large">Billing and plans</h2> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <section class="shithub-org-settings-section" aria-labelledby="org-billing-summary-heading"> | |
| 24 | + <h3 id="org-billing-summary-heading" class="sr-only">Billing summary</h3> | |
| 25 | + <div class="shithub-org-billing-summary"> | |
| 26 | + {{ range .Summary }} | |
| 27 | + <section class="Box shithub-org-billing-card"> | |
| 28 | + <div class="Box-header"> | |
| 29 | + <h4 class="Box-title">{{ .Label }}</h4> | |
| 30 | + </div> | |
| 31 | + <div class="Box-body"> | |
| 32 | + <p class="shithub-org-billing-card-value">{{ .Value }}</p> | |
| 33 | + {{ with .Detail }}<p class="shithub-org-billing-card-detail">{{ . }}</p>{{ end }} | |
| 34 | + </div> | |
| 35 | + </section> | |
| 36 | + {{ end }} | |
| 37 | + </div> | |
| 38 | + </section> | |
| 39 | + | |
| 40 | + <section class="shithub-org-settings-section" aria-labelledby="org-billing-manage-heading"> | |
| 41 | + <div class="Subhead Subhead--spacious border-bottom-0 mb-0 tmp-mb-0"> | |
| 42 | + <h2 id="org-billing-manage-heading" class="Subhead-heading">Manage plan</h2> | |
| 43 | + </div> | |
| 44 | + <div class="shithub-org-settings-box"> | |
| 45 | + <div class="shithub-org-settings-row"> | |
| 46 | + <div> | |
| 47 | + <strong>Team plan</strong> | |
| 48 | + <p>$4 per active organization member per month. Public and private repositories stay available; billing applies to hosted organization collaboration.</p> | |
| 49 | + </div> | |
| 50 | + {{ if .CanManageSubscription }} | |
| 51 | + <form method="POST" action="/organizations/{{ .Org.Slug }}/billing/portal"> | |
| 52 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 53 | + <button type="submit" class="shithub-button">Manage billing</button> | |
| 54 | + </form> | |
| 55 | + {{ else if .CanStartCheckout }} | |
| 56 | + <form method="POST" action="/organizations/{{ .Org.Slug }}/billing/checkout"> | |
| 57 | + <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> | |
| 58 | + <button type="submit" class="shithub-button shithub-button-primary">Upgrade to Team</button> | |
| 59 | + </form> | |
| 60 | + {{ else }} | |
| 61 | + <span class="shithub-empty-note">Billing is not configured on this instance.</span> | |
| 62 | + {{ end }} | |
| 63 | + </div> | |
| 64 | + <div class="shithub-org-settings-row"> | |
| 65 | + <div> | |
| 66 | + <strong>Billing email</strong> | |
| 67 | + <p>Stripe customer and invoice notifications use the organization billing email when one is set.</p> | |
| 68 | + </div> | |
| 69 | + <span>{{ if .Org.BillingEmail }}{{ .Org.BillingEmail }}{{ else }}Not set{{ end }}</span> | |
| 70 | + </div> | |
| 71 | + <div class="shithub-org-settings-row"> | |
| 72 | + <div> | |
| 73 | + <strong>Payment failures</strong> | |
| 74 | + <p>Past-due subscriptions enter a grace period before paid entitlements are cut off.</p> | |
| 75 | + </div> | |
| 76 | + <span>{{ .GracePeriodLabel }}</span> | |
| 77 | + </div> | |
| 78 | + </div> | |
| 79 | + </section> | |
| 80 | + | |
| 81 | + <section class="shithub-org-settings-section" aria-labelledby="org-billing-invoices-heading"> | |
| 82 | + <div class="Subhead Subhead--spacious border-bottom-0 mb-0 tmp-mb-0"> | |
| 83 | + <h2 id="org-billing-invoices-heading" class="Subhead-heading">Recent invoices</h2> | |
| 84 | + </div> | |
| 85 | + {{ if .Invoices }} | |
| 86 | + <div class="Box"> | |
| 87 | + <div class="Box-body p-0"> | |
| 88 | + <table class="shithub-org-billing-table"> | |
| 89 | + <thead> | |
| 90 | + <tr> | |
| 91 | + <th scope="col">Invoice</th> | |
| 92 | + <th scope="col">Status</th> | |
| 93 | + <th scope="col">Amount</th> | |
| 94 | + <th scope="col">Period</th> | |
| 95 | + <th scope="col">Due</th> | |
| 96 | + <th scope="col"></th> | |
| 97 | + </tr> | |
| 98 | + </thead> | |
| 99 | + <tbody> | |
| 100 | + {{ range .Invoices }} | |
| 101 | + <tr> | |
| 102 | + <td>{{ .Number }}</td> | |
| 103 | + <td><span class="shithub-org-billing-status is-{{ .StatusClass }}">{{ .StatusLabel }}</span></td> | |
| 104 | + <td>{{ .AmountLabel }}</td> | |
| 105 | + <td>{{ .PeriodLabel }}</td> | |
| 106 | + <td>{{ .DueLabel }}</td> | |
| 107 | + <td class="shithub-org-billing-links"> | |
| 108 | + {{ if .HostedInvoiceURL }}<a href="{{ .HostedInvoiceURL }}" target="_blank" rel="noreferrer">Open</a>{{ end }} | |
| 109 | + {{ if .InvoicePDFURL }}<a href="{{ .InvoicePDFURL }}" target="_blank" rel="noreferrer">PDF</a>{{ end }} | |
| 110 | + </td> | |
| 111 | + </tr> | |
| 112 | + {{ end }} | |
| 113 | + </tbody> | |
| 114 | + </table> | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | + {{ else }} | |
| 118 | + <p class="shithub-empty-note">No invoices have been recorded for this organization yet.</p> | |
| 119 | + {{ end }} | |
| 120 | + </section> | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | +</section> | |
| 124 | +{{- end }} | |
internal/web/templates/orgs/settings_import.htmlmodified@@ -10,26 +10,7 @@ | ||
| 10 | 10 | </header> |
| 11 | 11 | |
| 12 | 12 | <div class="shithub-org-settings-layout"> |
| 13 | - <aside class="shithub-org-settings-sidebar" aria-label="Organization settings"> | |
| 14 | - <h1>Settings</h1> | |
| 15 | - <nav class="shithub-org-settings-menu"> | |
| 16 | - <a href="/organizations/{{ .Org.Slug }}/settings/profile">{{ octicon "organization" }} General</a> | |
| 17 | - <a class="is-selected" href="/organizations/{{ .Org.Slug }}/settings/import" aria-current="page">{{ octicon "repo" }} Import repositories</a> | |
| 18 | - <span aria-disabled="true">{{ octicon "people" }} People</span> | |
| 19 | - <span aria-disabled="true">{{ octicon "repo" }} Repository defaults</span> | |
| 20 | - <span aria-disabled="true">{{ octicon "lock" }} Member privileges</span> | |
| 21 | - <h2>Code, planning, and automation</h2> | |
| 22 | - <span aria-disabled="true">{{ octicon "play" }} Actions</span> | |
| 23 | - <span aria-disabled="true">{{ octicon "table" }} Projects</span> | |
| 24 | - <span aria-disabled="true">{{ octicon "package" }} Packages</span> | |
| 25 | - <h2>Security</h2> | |
| 26 | - <span aria-disabled="true">{{ octicon "shield-check" }} Code security</span> | |
| 27 | - <span aria-disabled="true">{{ octicon "globe" }} Domains</span> | |
| 28 | - <h2>Access</h2> | |
| 29 | - <span aria-disabled="true">{{ octicon "gear" }} Integrations</span> | |
| 30 | - <span aria-disabled="true">{{ octicon "history" }} Audit log</span> | |
| 31 | - </nav> | |
| 32 | - </aside> | |
| 13 | + {{ template "org-settings-nav" . }} | |
| 33 | 14 | |
| 34 | 15 | <div class="shithub-org-settings-main"> |
| 35 | 16 | {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }} |
internal/web/templates/orgs/settings_profile.htmlmodified@@ -10,26 +10,7 @@ | ||
| 10 | 10 | </header> |
| 11 | 11 | |
| 12 | 12 | <div class="shithub-org-settings-layout"> |
| 13 | - <aside class="shithub-org-settings-sidebar" aria-label="Organization settings"> | |
| 14 | - <h1>Settings</h1> | |
| 15 | - <nav class="shithub-org-settings-menu"> | |
| 16 | - <a class="is-selected" href="/organizations/{{ .Org.Slug }}/settings/profile" aria-current="page">{{ octicon "organization" }} General</a> | |
| 17 | - <a href="/organizations/{{ .Org.Slug }}/settings/import">{{ octicon "repo" }} Import repositories</a> | |
| 18 | - <span aria-disabled="true">{{ octicon "people" }} People</span> | |
| 19 | - <span aria-disabled="true">{{ octicon "repo" }} Repository defaults</span> | |
| 20 | - <span aria-disabled="true">{{ octicon "lock" }} Member privileges</span> | |
| 21 | - <h2>Code, planning, and automation</h2> | |
| 22 | - <span aria-disabled="true">{{ octicon "play" }} Actions</span> | |
| 23 | - <span aria-disabled="true">{{ octicon "table" }} Projects</span> | |
| 24 | - <span aria-disabled="true">{{ octicon "package" }} Packages</span> | |
| 25 | - <h2>Security</h2> | |
| 26 | - <span aria-disabled="true">{{ octicon "shield-check" }} Code security</span> | |
| 27 | - <span aria-disabled="true">{{ octicon "globe" }} Domains</span> | |
| 28 | - <h2>Access</h2> | |
| 29 | - <span aria-disabled="true">{{ octicon "gear" }} Integrations</span> | |
| 30 | - <span aria-disabled="true">{{ octicon "history" }} Audit log</span> | |
| 31 | - </nav> | |
| 32 | - </aside> | |
| 13 | + {{ template "org-settings-nav" . }} | |
| 33 | 14 | |
| 34 | 15 | <div class="shithub-org-settings-main"> |
| 35 | 16 | {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }} |