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>
225 lines
6.9 KiB
Go
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})
|
|
}
|