veza/veza-backend-api/internal/handlers/subscription_handler.go
senke c0e2fe2e12 fix(v0.12.6.1): remediate remaining 15 MEDIUM + LOW pentest findings
MEDIUM-002: Remove manual X-Forwarded-For parsing in metrics_protection.go,
  use c.ClientIP() only (respects SetTrustedProxies)
MEDIUM-003: Pin ClamAV Docker image to 1.4 across all compose files
MEDIUM-004: Add clampLimit(100) to 15+ handlers that parsed limit directly
MEDIUM-006: Remove unsafe-eval from CSP script-src on Swagger routes
MEDIUM-007: Pin all GitHub Actions to SHA in 11 workflow files
MEDIUM-008: Replace rabbitmq:3-management-alpine with rabbitmq:3-alpine in prod
MEDIUM-009: Add trial-already-used check in subscription service
MEDIUM-010: Add 60s periodic token re-validation to WebSocket connections
MEDIUM-011: Mask email in auth handler logs with maskEmail() helper
MEDIUM-012: Add k-anonymity threshold (k=5) to playback analytics stats
LOW-001: Align frontend password policy to 12 chars (matching backend)
LOW-003: Replace deprecated dotenv with dotenvy crate in Rust stream server
LOW-004: Enable xpack.security in Elasticsearch dev/local compose files
LOW-005: Accept context.Context in CleanupExpiredSessions instead of Background()
LOW-002: Noted — Hyperswitch version update deferred (requires payment integration tests)

29/30 findings remediated. 1 noted (LOW-002).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:13:38 +01:00

225 lines
6.9 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"veza-backend-api/internal/core/subscription"
apperrors "veza-backend-api/internal/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SubscriptionHandler handles subscription-related HTTP endpoints
type SubscriptionHandler struct {
service *subscription.Service
logger *zap.Logger
}
// NewSubscriptionHandler creates a new SubscriptionHandler
func NewSubscriptionHandler(service *subscription.Service, logger *zap.Logger) *SubscriptionHandler {
return &SubscriptionHandler{
service: service,
logger: logger,
}
}
// ListPlans returns all available subscription plans
func (h *SubscriptionHandler) ListPlans(c *gin.Context) {
plans, err := h.service.ListPlans(c.Request.Context())
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list plans", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"plans": plans})
}
// GetPlan returns a specific plan by ID
func (h *SubscriptionHandler) GetPlan(c *gin.Context) {
planID, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid plan ID"))
return
}
plan, err := h.service.GetPlan(c.Request.Context(), planID)
if err != nil {
if errors.Is(err, subscription.ErrPlanNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("Plan"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get plan", err))
return
}
RespondSuccess(c, http.StatusOK, plan)
}
// GetMySubscription returns the authenticated user's current subscription
func (h *SubscriptionHandler) GetMySubscription(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
sub, err := h.service.GetUserSubscription(c.Request.Context(), userID)
if err != nil {
if errors.Is(err, subscription.ErrNoActiveSubscription) {
RespondSuccess(c, http.StatusOK, gin.H{"subscription": nil, "plan": "free"})
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get subscription", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"subscription": sub})
}
// Subscribe creates a new subscription for the authenticated user
func (h *SubscriptionHandler) Subscribe(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req subscription.SubscribeRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: plan_id and billing_cycle required"))
return
}
resp, err := h.service.Subscribe(c.Request.Context(), userID, req)
if err != nil {
switch {
case errors.Is(err, subscription.ErrPlanNotFound):
RespondWithAppError(c, apperrors.NewNotFoundError("Plan"))
case errors.Is(err, subscription.ErrAlreadySubscribed):
RespondWithAppError(c, apperrors.NewValidationError("Already subscribed to this plan"))
case errors.Is(err, subscription.ErrInvalidBillingCycle):
RespondWithAppError(c, apperrors.NewValidationError("Invalid billing cycle: must be 'monthly' or 'yearly'"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to subscribe", err))
}
return
}
RespondSuccess(c, http.StatusCreated, resp)
}
// CancelSubscription cancels the user's subscription at end of period
func (h *SubscriptionHandler) CancelSubscription(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
sub, err := h.service.CancelSubscription(c.Request.Context(), userID)
if err != nil {
switch {
case errors.Is(err, subscription.ErrNoActiveSubscription):
RespondWithAppError(c, apperrors.NewNotFoundError("Active subscription"))
case errors.Is(err, subscription.ErrFreePlanNoBilling):
RespondWithAppError(c, apperrors.NewValidationError("Free plan cannot be canceled"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to cancel subscription", err))
}
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"subscription": sub,
"message": "Subscription canceled. Access continues until " + sub.CurrentPeriodEnd.Format("2006-01-02"),
})
}
// ReactivateSubscription removes the cancellation of a subscription
func (h *SubscriptionHandler) ReactivateSubscription(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
sub, err := h.service.ReactivateSubscription(c.Request.Context(), userID)
if err != nil {
if errors.Is(err, subscription.ErrNoActiveSubscription) {
RespondWithAppError(c, apperrors.NewNotFoundError("Active subscription"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to reactivate subscription", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"subscription": sub})
}
// ChangeBillingCycle switches between monthly and yearly billing
func (h *SubscriptionHandler) ChangeBillingCycle(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req struct {
BillingCycle subscription.BillingCycle `json:"billing_cycle" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid request: billing_cycle required"))
return
}
sub, err := h.service.ChangeBillingCycle(c.Request.Context(), userID, req.BillingCycle)
if err != nil {
switch {
case errors.Is(err, subscription.ErrNoActiveSubscription):
RespondWithAppError(c, apperrors.NewNotFoundError("Active subscription"))
case errors.Is(err, subscription.ErrInvalidBillingCycle):
RespondWithAppError(c, apperrors.NewValidationError("Invalid billing cycle: must be 'monthly' or 'yearly'"))
default:
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to change billing cycle", err))
}
return
}
RespondSuccess(c, http.StatusOK, gin.H{"subscription": sub})
}
// GetInvoices returns the user's subscription invoices
func (h *SubscriptionHandler) GetInvoices(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
invoices, err := h.service.GetUserInvoices(c.Request.Context(), userID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get invoices", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"invoices": invoices})
}
// GetSubscriptionHistory returns the user's subscription history
func (h *SubscriptionHandler) GetSubscriptionHistory(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
subs, err := h.service.GetUserSubscriptionHistory(c.Request.Context(), userID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get subscription history", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"subscriptions": subs})
}