A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
428 lines
11 KiB
Go
428 lines
11 KiB
Go
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,
|
|
})
|
|
}
|