Files
descrybe/apps/api/internal/httpapi/billing_handlers.go
T

158 lines
5.2 KiB
Go
Raw Normal View History

package httpapi
import (
"errors"
"log"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
)
func (s *Server) handleCreditsOverview(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
overview, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load credits")
return
}
JSON(w, http.StatusOK, overview)
}
func (s *Server) handleBillingUsage(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
usage, err := s.Billing.UsageSummary(r.Context(), cid, r.URL.Query().Get("range"))
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load usage")
return
}
JSON(w, http.StatusOK, usage)
}
// handleListPlans returns every plan (including client deals) for platform admin.
func (s *Server) handleListPlans(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
_ = s.Billing.EnsureDefaultPlans(r.Context())
plans, err := s.Billing.ListPlans(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "failed to list plans")
return
}
// Permission matrices are admin-sensitive; never let shared caches retain them.
w.Header().Set("Cache-Control", "private, no-store")
JSON(w, http.StatusOK, map[string]any{"plans": plans})
}
// handleListPublicPlans returns Free/Starter/Plus/Growth/Business/Scale/Enterprise only.
// Hides client-specific deals (A1, Merkur trial, legacy ladders) from company UI.
func (s *Server) handleListPublicPlans(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil || s.Billing.Pool == nil {
// Empty billing must not 500 on the public pricing surface.
JSON(w, http.StatusOK, map[string]any{
"plans": []any{},
"credits_per_ai_product": billing.CreditsPerAIProduct,
"assumed_content_langs": billing.AssumedPrimaryContentLanguages,
})
return
}
_ = s.Billing.EnsureDefaultPlans(r.Context())
plans, err := s.Billing.ListPublicPlans(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "failed to list plans")
return
}
JSON(w, http.StatusOK, map[string]any{
"plans": plans,
"credits_per_ai_product": billing.CreditsPerAIProduct,
"assumed_content_langs": billing.AssumedPrimaryContentLanguages,
})
}
// handleListCreditPacks returns one-time AI credit top-up packages for Checkout.
func (s *Server) handleListCreditPacks(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, map[string]any{"packs": billing.DefaultCreditPacks()})
}
func (s *Server) handleUpsertPlan(w http.ResponseWriter, r *http.Request) {
var body billing.Plan
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
plan, err := s.Billing.UpsertPlan(r.Context(), body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not save plan", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, plan)
}
func (s *Server) handleAssignPlan(w http.ResponseWriter, r *http.Request) {
var body struct {
CompanyID string `json:"company_id"`
PlanID int64 `json:"plan_id"`
IsTrial bool `json:"is_trial"`
TrialCredits int `json:"trial_credits"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cid, err := uuid.Parse(body.CompanyID)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company_id")
return
}
if body.PlanID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan_id")
return
}
if err := s.Billing.AssignPlan(r.Context(), cid, body.PlanID, body.IsTrial, body.TrialCredits); err != nil {
if errors.Is(err, billing.ErrPlanNotFound) {
Error(w, http.StatusNotFound, billing.ErrPlanNotFound.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, "could not assign plan", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleAddCredits(w http.ResponseWriter, r *http.Request) {
var body struct {
CompanyID string `json:"company_id"`
Amount int `json:"amount"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cid, err := uuid.Parse(body.CompanyID)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company_id")
return
}
if err := s.Billing.AddCredits(r.Context(), cid, body.Amount); err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not add credits", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleRunBillingCycles(w http.ResponseWriter, r *http.Request) {
res, err := s.Billing.RunDueBillingCycles(r.Context())
if err != nil && res.Processed == 0 && res.Failed == 0 {
LogAndError(w, http.StatusInternalServerError, "billing cycle run failed", err)
return
}
out := map[string]any{"processed": res.Processed, "failed": res.Failed}
if err != nil {
log.Printf("httpapi: billing cycle run completed with errors: %v", err)
out["error"] = "billing cycle run completed with errors"
}
JSON(w, http.StatusOK, out)
}