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>
240 lines
7.6 KiB
Go
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})
|
|
}
|