213 lines
6.9 KiB
Go
213 lines
6.9 KiB
Go
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)
|
|
}
|