package handlers import ( "net/http" "github.com/breakpilot/billing-service/internal/database" "github.com/breakpilot/billing-service/internal/middleware" "github.com/breakpilot/billing-service/internal/models" "github.com/breakpilot/billing-service/internal/services" "github.com/gin-gonic/gin" ) // BillingHandler handles billing-related HTTP requests type BillingHandler struct { db *database.DB subscriptionService *services.SubscriptionService stripeService *services.StripeService entitlementService *services.EntitlementService usageService *services.UsageService } // NewBillingHandler creates a new BillingHandler func NewBillingHandler( db *database.DB, subscriptionService *services.SubscriptionService, stripeService *services.StripeService, entitlementService *services.EntitlementService, usageService *services.UsageService, ) *BillingHandler { return &BillingHandler{ db: db, subscriptionService: subscriptionService, stripeService: stripeService, entitlementService: entitlementService, usageService: usageService, } } // GetBillingStatus returns the current billing status for a user // GET /api/v1/billing/status func (h *BillingHandler) GetBillingStatus(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID.String() == "" { c.JSON(http.StatusUnauthorized, gin.H{ "error": "unauthorized", "message": "User not authenticated", }) return } ctx := c.Request.Context() // Get subscription subscription, err := h.subscriptionService.GetByUserID(ctx, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "internal_error", "message": "Failed to get subscription", }) return } // Get available plans plans, err := h.subscriptionService.GetAvailablePlans(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "internal_error", "message": "Failed to get plans", }) return } response := models.BillingStatusResponse{ HasSubscription: subscription != nil, AvailablePlans: plans, } if subscription != nil { // Get plan details plan, _ := h.subscriptionService.GetPlanByID(ctx, string(subscription.PlanID)) subInfo := &models.SubscriptionInfo{ PlanID: subscription.PlanID, Status: subscription.Status, IsTrialing: subscription.Status == models.StatusTrialing, CancelAtPeriodEnd: subscription.CancelAtPeriodEnd, CurrentPeriodEnd: subscription.CurrentPeriodEnd, } if plan != nil { subInfo.PlanName = plan.Name subInfo.PriceCents = plan.PriceCents subInfo.Currency = plan.Currency } // Calculate trial days left if subscription.TrialEnd != nil && subscription.Status == models.StatusTrialing { // TODO: Calculate days left } response.Subscription = subInfo // Get task usage info (legacy usage tracking - see TaskService for new task-based usage) // TODO: Replace with TaskService.GetTaskUsageInfo for task-based billing _, _ = h.usageService.GetUsageSummary(ctx, userID) // Get entitlements entitlements, _ := h.entitlementService.GetEntitlements(ctx, userID) if entitlements != nil { response.Entitlements = entitlements } } c.JSON(http.StatusOK, response) } // GetPlans returns all available billing plans // GET /api/v1/billing/plans func (h *BillingHandler) GetPlans(c *gin.Context) { ctx := c.Request.Context() plans, err := h.subscriptionService.GetAvailablePlans(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "internal_error", "message": "Failed to get plans", }) return } c.JSON(http.StatusOK, gin.H{ "plans": plans, }) } // StartTrial starts a trial for the user with a specific plan // POST /api/v1/billing/trial/start func (h *BillingHandler) StartTrial(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID.String() == "" { c.JSON(http.StatusUnauthorized, gin.H{ "error": "unauthorized", "message": "User not authenticated", }) return } var req models.StartTrialRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid_request", "message": "Invalid request body", }) return } ctx := c.Request.Context() // Check if user already has a subscription existing, _ := h.subscriptionService.GetByUserID(ctx, userID) if existing != nil { c.JSON(http.StatusConflict, gin.H{ "error": "subscription_exists", "message": "User already has a subscription", }) return } // Get user email from context email, _ := c.Get("email") emailStr, _ := email.(string) // Create Stripe checkout session checkoutURL, sessionID, err := h.stripeService.CreateCheckoutSession(ctx, userID, emailStr, req.PlanID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "stripe_error", "message": "Failed to create checkout session", "details": err.Error(), }) return } c.JSON(http.StatusOK, models.StartTrialResponse{ CheckoutURL: checkoutURL, SessionID: sessionID, }) } // ChangePlan changes the user's subscription plan // POST /api/v1/billing/change-plan func (h *BillingHandler) ChangePlan(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID.String() == "" { c.JSON(http.StatusUnauthorized, gin.H{ "error": "unauthorized", "message": "User not authenticated", }) return } var req models.ChangePlanRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid_request", "message": "Invalid request body", }) return } ctx := c.Request.Context() // Get current subscription subscription, err := h.subscriptionService.GetByUserID(ctx, userID) if err != nil || subscription == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "no_subscription", "message": "No active subscription found", }) return } // Change plan via Stripe err = h.stripeService.ChangePlan(ctx, subscription.StripeSubscriptionID, req.NewPlanID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "stripe_error", "message": "Failed to change plan", "details": err.Error(), }) return } c.JSON(http.StatusOK, models.ChangePlanResponse{ Success: true, Message: "Plan changed successfully", }) } // CancelSubscription cancels the user's subscription at period end // POST /api/v1/billing/cancel func (h *BillingHandler) CancelSubscription(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID.String() == "" { c.JSON(http.StatusUnauthorized, gin.H{ "error": "unauthorized", "message": "User not authenticated", }) return } ctx := c.Request.Context() // Get current subscription subscription, err := h.subscriptionService.GetByUserID(ctx, userID) if err != nil || subscription == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "no_subscription", "message": "No active subscription found", }) return } // Cancel at period end via Stripe err = h.stripeService.CancelSubscription(ctx, subscription.StripeSubscriptionID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "stripe_error", "message": "Failed to cancel subscription", "details": err.Error(), }) return } c.JSON(http.StatusOK, models.CancelSubscriptionResponse{ Success: true, Message: "Subscription will be canceled at the end of the billing period", }) } // GetCustomerPortal returns a URL to the Stripe customer portal // GET /api/v1/billing/portal func (h *BillingHandler) GetCustomerPortal(c *gin.Context) { userID, err := middleware.GetUserID(c) if err != nil || userID.String() == "" { c.JSON(http.StatusUnauthorized, gin.H{ "error": "unauthorized", "message": "User not authenticated", }) return } ctx := c.Request.Context() // Get current subscription subscription, err := h.subscriptionService.GetByUserID(ctx, userID) if err != nil || subscription == nil || subscription.StripeCustomerID == "" { c.JSON(http.StatusNotFound, gin.H{ "error": "no_subscription", "message": "No active subscription found", }) return } // Create portal session portalURL, err := h.stripeService.CreateCustomerPortalSession(ctx, subscription.StripeCustomerID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "stripe_error", "message": "Failed to create portal session", "details": err.Error(), }) return } c.JSON(http.StatusOK, models.CustomerPortalResponse{ PortalURL: portalURL, }) } // ============================================= // Internal Endpoints (Service-to-Service) // ============================================= // GetEntitlements returns entitlements for a user (internal) // GET /api/v1/billing/entitlements/:userId func (h *BillingHandler) GetEntitlements(c *gin.Context) { userIDStr := c.Param("userId") ctx := c.Request.Context() entitlements, err := h.entitlementService.GetEntitlementsByUserIDString(ctx, userIDStr) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "internal_error", "message": "Failed to get entitlements", }) return } if entitlements == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "not_found", "message": "No entitlements found for user", }) return } c.JSON(http.StatusOK, entitlements) } // TrackUsage tracks usage for a user (internal) // POST /api/v1/billing/usage/track func (h *BillingHandler) TrackUsage(c *gin.Context) { var req models.TrackUsageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid_request", "message": "Invalid request body", }) return } ctx := c.Request.Context() quantity := req.Quantity if quantity <= 0 { quantity = 1 } err := h.usageService.TrackUsage(ctx, req.UserID, req.UsageType, quantity) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "internal_error", "message": "Failed to track usage", }) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Usage tracked", }) } // CheckUsage checks if usage is allowed (internal) // GET /api/v1/billing/usage/check/:userId/:type func (h *BillingHandler) CheckUsage(c *gin.Context) { userIDStr := c.Param("userId") usageType := c.Param("type") ctx := c.Request.Context() response, err := h.usageService.CheckUsageAllowed(ctx, userIDStr, usageType) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "internal_error", "message": "Failed to check usage", }) return } c.JSON(http.StatusOK, response) } // CheckEntitlement checks if a user has a specific entitlement (internal) // GET /api/v1/billing/entitlements/check/:userId/:feature func (h *BillingHandler) CheckEntitlement(c *gin.Context) { userIDStr := c.Param("userId") feature := c.Param("feature") ctx := c.Request.Context() hasEntitlement, planID, err := h.entitlementService.CheckEntitlement(ctx, userIDStr, feature) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "internal_error", "message": "Failed to check entitlement", }) return } c.JSON(http.StatusOK, models.EntitlementCheckResponse{ HasEntitlement: hasEntitlement, PlanID: planID, }) }