package httpapi import ( "errors" "net/http" "strconv" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) // GET /api/billing/capabilities — effective plan ∩ global ∩ member features for the caller. func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } cid, ok := CompanyIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "company required") return } // Member-aware: the company owner's per-member overlay narrows what this caller // sees, so nav / route guard / feature gates all hide the same surfaces at once. uid, _ := UserIDFromContext(r.Context()) caps, err := s.Billing.CapabilitiesForMember(r.Context(), cid, uid) if err != nil { Error(w, http.StatusInternalServerError, "failed to load capabilities") return } etag := billing.CapabilitiesResponseETag(caps) // Private: company-scoped. Short max-age + ETag mirrors OpenAPI conditional GET pattern. w.Header().Set("Cache-Control", "private, max-age=30, must-revalidate") w.Header().Set("ETag", etag) if match := r.Header.Get("If-None-Match"); match != "" && match == etag { w.WriteHeader(http.StatusNotModified) return } JSON(w, http.StatusOK, caps) } // GET /api/admin/plans/{planID}/features func (s *Server) handleAdminGetPlanFeatures(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64) if err != nil || planID <= 0 { Error(w, http.StatusBadRequest, "invalid plan id") return } view, err := s.Billing.GetPlanFeatures(r.Context(), planID) if err != nil { writePlanFeaturesErr(w, "could not load plan features", err) return } w.Header().Set("Cache-Control", "private, no-store") JSON(w, http.StatusOK, view) } // PUT /api/admin/plans/{planID}/features — replaces stored feature overrides. func (s *Server) handleAdminPutPlanFeatures(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64) if err != nil || planID <= 0 { Error(w, http.StatusBadRequest, "invalid plan id") return } var body billing.PlanFeaturesUpdate if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } if body.Features == nil { Error(w, http.StatusBadRequest, "features required") return } view, err := s.Billing.SetPlanFeatures(r.Context(), planID, body.Features) if err != nil { writePlanFeaturesErr(w, "could not save plan features", err) return } JSON(w, http.StatusOK, view) } // POST /api/admin/plans/{planID}/features/enable-all — sets every registry key true (custom packages). func (s *Server) handleAdminEnableAllPlanFeatures(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64) if err != nil || planID <= 0 { Error(w, http.StatusBadRequest, "invalid plan id") return } view, err := s.Billing.EnableAllPlanFeatures(r.Context(), planID) if err != nil { writePlanFeaturesErr(w, "could not enable plan features", err) return } JSON(w, http.StatusOK, view) } // POST /api/admin/plans/{planID}/features/disable-all — sets every registry key false. func (s *Server) handleAdminDisableAllPlanFeatures(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64) if err != nil || planID <= 0 { Error(w, http.StatusBadRequest, "invalid plan id") return } view, err := s.Billing.DisableAllPlanFeatures(r.Context(), planID) if err != nil { writePlanFeaturesErr(w, "could not disable plan features", err) return } JSON(w, http.StatusOK, view) } // GET /api/admin/feature-gates func (s *Server) handleAdminGetFeatureGates(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } view, err := s.Billing.GetFeatureGates(r.Context()) if err != nil { Error(w, http.StatusInternalServerError, "failed to load feature gates") return } w.Header().Set("Cache-Control", "private, no-store") JSON(w, http.StatusOK, view) } // PUT /api/admin/feature-gates — partial upsert of section/feature master switches. func (s *Server) handleAdminPutFeatureGates(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } var body billing.FeatureGatesUpdate if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } if body.Sections == nil && body.Features == nil { Error(w, http.StatusBadRequest, "sections or features required") return } var updatedBy *uuid.UUID if uid, ok := UserIDFromContext(r.Context()); ok { updatedBy = &uid } view, err := s.Billing.SetFeatureGates(r.Context(), body.Sections, body.Features, updatedBy) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update feature gates", err, billing.ClientError) return } JSON(w, http.StatusOK, view) } // PUT /api/admin/feature-gates/sections/{section} — enable/disable a section for ALL plans. func (s *Server) handleAdminPutFeatureGateSection(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } section := strings.TrimSpace(chi.URLParam(r, "section")) if section == "" { Error(w, http.StatusBadRequest, "section required") return } var body billing.SectionGateUpdate if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } if body.Enabled == nil { Error(w, http.StatusBadRequest, "enabled required") return } var updatedBy *uuid.UUID if uid, ok := UserIDFromContext(r.Context()); ok { updatedBy = &uid } view, err := s.Billing.SetSectionGate(r.Context(), section, *body.Enabled, updatedBy) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update section gate", err, billing.ClientError) return } JSON(w, http.StatusOK, view) } // writePlanFeaturesErr maps known billing client errors to the correct status // (404 for missing plan; 400 for validation). func writePlanFeaturesErr(w http.ResponseWriter, publicFallback string, err error) { if errors.Is(err, billing.ErrPlanNotFound) { Error(w, http.StatusNotFound, billing.ErrPlanNotFound.Error()) return } ClientOrLog(w, http.StatusBadRequest, publicFallback, err, billing.ClientError) }