veza/veza-backend-api/internal/handlers/subscription_handler.go
senke 9a8d2a4e73 chore(release): v1.0.6.2 — subscription payment-gate bypass hotfix
Closes a bypass surfaced by the 2026-04 audit probe (axis-1 Q2): any
authenticated user could POST /api/v1/subscriptions/subscribe on a paid
plan and receive 201 active without the payment provider ever being
invoked. The resulting row satisfied `checkEligibility()` in the
distribution service via `can_sell_on_marketplace=true` on the Creator
plan — effectively free access to /api/v1/distribution/submit, which
dispatches to external partners.

Fix is centralised in `GetUserSubscription` so there is no code path
that can grant subscription-gated access without routing through the
payment check. Effective-payment = free plan OR unexpired trial OR
invoice with non-empty hyperswitch_payment_id. Migration 980 sweeps
pre-existing fantôme rows into `expired`, preserving the tuple in a
dated audit table for support outreach.

Subscribe and subscribeToFreePlan treat the new ErrSubscriptionNoPayment
as equivalent to ErrNoActiveSubscription so re-subscription works
cleanly post-cleanup. GET /me/subscription surfaces needs_payment=true
with a support-contact message rather than a misleading "you're on
free" or an opaque 500. TODO(v1.0.7-item-G) annotation marks where the
`if s.paymentProvider != nil` short-circuit needs to become a mandatory
pending_payment state.

Probe script `scripts/probes/subscription-unpaid-activation.sh` kept as
a versioned regression test — dry-run by default, --destructive logs in
and attempts the exploit against a live backend with automatic cleanup.
8-case unit test matrix covers the full hasEffectivePayment predicate.

Smoke validated end-to-end against local v1.0.6.2: POST /subscribe
returns 201 (by design — item G closes the creation path), but
GET /me/subscription returns subscription=null + needs_payment=true,
distribution eligibility returns false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:21:53 +02:00

240 lines
7.6 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
}
// 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'"))
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),
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})
}