Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
427
billing-service/internal/handlers/billing_handlers.go
Normal file
427
billing-service/internal/handlers/billing_handlers.go
Normal file
@@ -0,0 +1,427 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user