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 } // v1.0.6.2: a subscription row exists but has no payment linkage. // Surface a specific payload so honest-path users who landed here // via a broken flow (payment never completed) get a clear message // rather than "you're on free" (misleading) or a 500. if errors.Is(err, subscription.ErrSubscriptionNoPayment) { RespondSuccess(c, http.StatusOK, gin.H{ "subscription": nil, "plan": "free", "needs_payment": true, "message": "Your subscription is not linked to a payment. Please contact support to resolve.", }) 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'")) case errors.Is(err, subscription.ErrPaymentProviderRequired): // v1.0.9 item G: paid plan attempted but no PaymentProvider // is wired (HYPERSWITCH_ENABLED=false in dev, or missing // credentials in staging). Surface the misconfig as 503 so // ops sees it instead of silently absorbing it as a free // active subscription. RespondWithAppError(c, apperrors.NewServiceUnavailableError("Payment provider not configured — paid plans temporarily unavailable")) default: RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to subscribe", err)) } return } RespondSuccess(c, http.StatusCreated, resp) } // Complete recovers the PSP client_secret for a subscription stuck in // pending_payment so the frontend can drive the payment UI to // completion. v1.0.9 item G Phase 3 — closes the recovery loop. // // Path: POST /api/v1/subscriptions/complete/:id // Auth: required, ownership enforced server-side (sub.user_id = caller). // Returns: 200 {subscription, client_secret, payment_id} on success. // // 404 if the subscription doesn't belong to the caller. // 409 if the subscription is not in pending_payment. // 503 if HYPERSWITCH_ENABLED=false. // // The PSP CreateSubscriptionPayment call is idempotent on sub.ID, so // this endpoint is safe to retry from the frontend on transient // network failures — the same payment intent is returned each time. func (h *SubscriptionHandler) Complete(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } idStr := c.Param("id") subID, err := uuid.Parse(idStr) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid subscription id")) return } resp, err := h.service.CompletePendingPayment(c.Request.Context(), userID, subID) if err != nil { switch { case errors.Is(err, subscription.ErrSubscriptionNotFound): RespondWithAppError(c, apperrors.NewNotFoundError("Subscription")) case errors.Is(err, subscription.ErrSubscriptionNotPending): // 409 — the subscription has already transitioned out of // pending_payment (webhook arrived, manual admin action, // upgrade). Caller should refresh /me to see current state. RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "Subscription is not in pending_payment state")) case errors.Is(err, subscription.ErrPaymentProviderRequired): RespondWithAppError(c, apperrors.NewServiceUnavailableError("Payment provider not configured — payment recovery unavailable")) default: RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to recover payment", err)) } return } RespondSuccess(c, http.StatusOK, 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), errors.Is(err, subscription.ErrSubscriptionNoPayment): 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) || errors.Is(err, subscription.ErrSubscriptionNoPayment) { 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), errors.Is(err, subscription.ErrSubscriptionNoPayment): 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}) }