mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-18 16:01:18 +00:00
378 lines
11 KiB
Go
378 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// GetSubscription returns the current organization's subscription
|
|
func (h *Handler) GetSubscription(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
sub, errX := h.SubscriptionService.Get(c.Request.Context(), *orgID)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, sub)
|
|
}
|
|
|
|
// GetSubscriptionLimits returns the current organization's subscription with rate limits
|
|
func (h *Handler) GetSubscriptionLimits(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
sub, errX := h.SubscriptionService.GetWithLimits(c.Request.Context(), *orgID)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, sub)
|
|
}
|
|
|
|
// ListPlans returns available subscription plans
|
|
func (h *Handler) ListPlans(c *gin.Context) {
|
|
plans, errX := h.SubscriptionService.ListPlans(c.Request.Context(), true)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"plans": plans})
|
|
}
|
|
|
|
// CreateCheckoutSession creates a Stripe checkout session
|
|
func (h *Handler) CreateCheckoutSession(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
uid, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Unauthorized, "invalid user"))
|
|
return
|
|
}
|
|
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
PriceID string `json:"price_id" binding:"required"`
|
|
SuccessURL string `json:"success_url" binding:"required"`
|
|
CancelURL string `json:"cancel_url" binding:"required"`
|
|
DiscountCode string `json:"discount_code"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
|
|
session, errX := h.StripeService.CreateCheckoutSession(c.Request.Context(), uid, *orgID, req.PriceID, req.SuccessURL, req.CancelURL, req.DiscountCode)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionCreate, models.AuditEntitySubscription, nil, nil, map[string]string{
|
|
"action": "checkout",
|
|
"price_id": req.PriceID,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"session_id": session.ID,
|
|
"checkout_url": session.URL,
|
|
})
|
|
}
|
|
|
|
// CreateBillingPortalSession creates a Stripe billing portal session
|
|
func (h *Handler) CreateBillingPortalSession(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
ReturnURL string `json:"return_url" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
|
|
sub, errX := h.SubscriptionService.Get(c.Request.Context(), *orgID)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
portalURL, errX := h.StripeService.CreatePortalSession(c.Request.Context(), sub.StripeCustomerID, req.ReturnURL)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"portal_url": portalURL})
|
|
}
|
|
|
|
// CancelSubscription cancels the current organization's subscription
|
|
func (h *Handler) CancelSubscription(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.ErrInvalid)
|
|
return
|
|
}
|
|
|
|
sub, errX := h.SubscriptionService.Get(c.Request.Context(), *orgID)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
if sub.StripeSubscriptionID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no active subscription"))
|
|
return
|
|
}
|
|
|
|
errX = h.StripeService.CancelSubscription(c.Request.Context(), *sub.StripeSubscriptionID, req.CancelAtPeriodEnd)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
subID := sub.ID
|
|
action := models.AuditActionResume
|
|
if req.CancelAtPeriodEnd {
|
|
action = models.AuditActionStop
|
|
}
|
|
h.auditOrg(c, action, models.AuditEntitySubscription, &subID, nil, map[string]string{
|
|
"plan": sub.PlanID.String(),
|
|
"cancel_at_period_end": strconv.FormatBool(req.CancelAtPeriodEnd),
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "subscription cancelled"})
|
|
}
|
|
|
|
// HandleStripeWebhook processes Stripe webhook events
|
|
func (h *Handler) HandleStripeWebhook(c *gin.Context) {
|
|
payload, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "failed to read request body"))
|
|
return
|
|
}
|
|
|
|
signature := c.GetHeader("Stripe-Signature")
|
|
if signature == "" {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "missing stripe signature"))
|
|
return
|
|
}
|
|
|
|
event, errX := h.StripeService.VerifyWebhook(payload, signature)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
errX = h.StripeService.ProcessWebhookEvent(c.Request.Context(), event)
|
|
if errX != nil {
|
|
// Return a retryable 5xx so Stripe re-delivers. The event was NOT
|
|
// recorded as processed (see ProcessWebhookEvent), and every handler is
|
|
// idempotent on the event id, so a retry safely re-runs without
|
|
// double-applying. Silently 200-ing here would strand paid-for credits.
|
|
c.JSON(http.StatusInternalServerError, gin.H{"received": false, "error": errX.Message})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"received": true})
|
|
}
|
|
|
|
// GetTrialStatus returns the current organization's free trial status
|
|
func (h *Handler) GetTrialStatus(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
if h.TrialService == nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "trial service not available"))
|
|
return
|
|
}
|
|
|
|
status, errX := h.TrialService.GetTrialStatus(c.Request.Context(), *orgID)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, status)
|
|
}
|
|
|
|
// GetFeatureStatus returns the current organization's feature access status
|
|
func (h *Handler) GetFeatureStatus(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
if h.FeatureGateService == nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "feature gate service not available"))
|
|
return
|
|
}
|
|
|
|
status, errX := h.FeatureGateService.GetSubscriptionStatus(c.Request.Context(), *orgID)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
// Add additional feature flags
|
|
canSend, _ := h.FeatureGateService.CanSendCampaignEmail(c.Request.Context(), *orgID)
|
|
canWarmup, _ := h.FeatureGateService.CanUseWarmup(c.Request.Context(), *orgID)
|
|
canUnibox, _ := h.FeatureGateService.CanUseUnibox(c.Request.Context(), *orgID)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"subscription": status,
|
|
"can_send_campaigns": canSend,
|
|
"can_use_warmup": canWarmup,
|
|
"can_use_unibox": canUnibox,
|
|
})
|
|
}
|
|
|
|
// ChangePlan changes the organization's subscription plan with proration
|
|
func (h *Handler) ChangePlan(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
PlanID uuid.UUID `json:"plan_id" binding:"required"`
|
|
ProrationBehavior string `json:"proration_behavior"` // "create_prorations", "always_invoice", "none"
|
|
DiscountCode string `json:"discount_code"`
|
|
Interval string `json:"interval"` // "month" or "year"; defaults to monthly
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
|
|
updated, errX := h.StripeService.ChangePlan(c.Request.Context(), *orgID, req.PlanID, req.ProrationBehavior, req.DiscountCode, req.Interval)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
planID := req.PlanID
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntitySubscription, &planID, nil, map[string]string{
|
|
"action": "change_plan",
|
|
"plan": req.PlanID.String(),
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "plan changed successfully",
|
|
"subscription": updated,
|
|
})
|
|
}
|
|
|
|
// PreviewPlanChange previews the proration for a plan change
|
|
func (h *Handler) PreviewPlanChange(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
newPlanIDStr := c.Query("new_plan_id")
|
|
if newPlanIDStr == "" {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "new_plan_id is required"))
|
|
return
|
|
}
|
|
|
|
newPlanID, err := uuid.Parse(newPlanIDStr)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid plan ID"))
|
|
return
|
|
}
|
|
|
|
preview, errX := h.StripeService.PreviewPlanChange(c.Request.Context(), *orgID, newPlanID)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, preview)
|
|
}
|
|
|
|
// EnterpriseInquiryRequest represents a request for enterprise pricing
|
|
type EnterpriseInquiryRequest struct {
|
|
CompanyName string `json:"company_name" binding:"required"`
|
|
ContactName string `json:"contact_name" binding:"required"`
|
|
ContactEmail string `json:"contact_email" binding:"required,email"`
|
|
EstimatedVolume *int `json:"estimated_volume,omitempty"`
|
|
TeamSize *int `json:"team_size,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// SubmitEnterpriseInquiry submits an enterprise pricing inquiry
|
|
func (h *Handler) SubmitEnterpriseInquiry(c *gin.Context) {
|
|
var req EnterpriseInquiryRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
|
|
inquiry := &models.EnterpriseInquiry{
|
|
CompanyName: req.CompanyName,
|
|
ContactName: req.ContactName,
|
|
ContactEmail: req.ContactEmail,
|
|
EstimatedVolume: req.EstimatedVolume,
|
|
TeamSize: req.TeamSize,
|
|
Notes: req.Notes,
|
|
}
|
|
|
|
// Get organization ID if available (user might be authenticated)
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID != nil {
|
|
// Could track which organization made the inquiry
|
|
inquiry.Notes = inquiry.Notes + " [Organization: " + orgID.String() + "]"
|
|
}
|
|
|
|
created, errX := h.OrganizationService.CreateEnterpriseInquiry(c.Request.Context(), inquiry)
|
|
if errX != nil {
|
|
errx.JSON(c, errX)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Thank you! Our team will contact you within 24 hours.",
|
|
"inquiry_id": created.ID,
|
|
})
|
|
}
|