veza/veza-backend-api/internal/handlers/subscription_handler.go
senke 7e26a8dd1f
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 4m19s
Security Scan / Secret Scanning (gitleaks) (push) Successful in 1m4s
Veza CI / Frontend (Web) (push) Failing after 16m42s
Veza CI / Backend (Go) (push) Failing after 19m28s
Veza CI / Notify on failure (push) Successful in 15s
E2E Playwright / e2e (full) (push) Failing after 19m56s
feat(subscription): recovery endpoint + distribution gate (v1.0.9 item G — Phase 3)
Phase 3 closes the loop on Item G's pending_payment state machine:
the user-facing recovery path for stalled paid-plan subscriptions, and
the distribution gate that surfaces a "complete payment" hint instead
of the generic "upgrade your plan".

Recovery endpoint — POST /api/v1/subscriptions/complete/:id

  Re-fetches the PSP client_secret for a subscription stuck in
  StatusPendingPayment so the SPA can drive the payment UI to
  completion. The PSP CreateSubscriptionPayment call is idempotent on
  sub.ID.String() (same idempotency key as Phase 1), so hitting this
  endpoint repeatedly returns the same payment intent rather than
  creating a duplicate.

  Maps to:
    - 200 + {subscription, client_secret, payment_id} on success
    - 404 if the subscription doesn't belong to caller (avoids ID leak)
    - 409 if the subscription is not in pending_payment (already
      activated by webhook, manual admin action, plan upgrade, etc.)
    - 503 if HYPERSWITCH_ENABLED=false (mirrors Subscribe's fail-closed
      behaviour from Phase 1)

  Service surface:
    - subscription.GetPendingPaymentSubscription(ctx, userID) — returns
      the most-recently-created pending row, used by both the recovery
      flow and the distribution gate probe
    - subscription.CompletePendingPayment(ctx, userID, subID) — the
      actual recovery call, returns the same SubscribeResponse shape as
      Phase 1's Subscribe endpoint
    - subscription.ErrSubscriptionNotPending — sentinel for the 409
    - subscription.ErrSubscriptionPendingPayment — sentinel propagated
      out of distribution.checkEligibility

Distribution gate — distinct path for pending_payment

  Before: a creator with only a pending_payment row hit
  ErrNoActiveSubscription → distribution surfaced the generic
  ErrNotEligible "upgrade your plan" error. Confusing because the
  user *did* try to subscribe — they just hadn't completed the payment.

  After: distribution.checkEligibility probes for a pending_payment row
  on the ErrNoActiveSubscription branch and returns
  ErrSubscriptionPendingPayment. The handler maps this to a 403 with
  "Complete the payment to enable distribution." so the SPA can route
  to the recovery page instead of the upgrade page.

Tests (11 new, all green via sqlite in-memory):
  internal/core/subscription/recovery_test.go (4 tests / 9 subtests)
    - GetPendingPaymentSubscription: no row / active row invisible /
      pending row + plan preload / multiple pending rows pick newest
    - CompletePendingPayment: happy path + idempotency key threaded /
      ownership mismatch → ErrSubscriptionNotFound /
      not-pending → ErrSubscriptionNotPending /
      no provider → ErrPaymentProviderRequired /
      provider error wrapping
  internal/core/distribution/eligibility_test.go (2 tests)
    - Submit_EligibilityGate_PendingPayment: pending_payment user
      gets ErrSubscriptionPendingPayment (recovery hint)
    - Submit_EligibilityGate_NoSubscription: no-sub user gets
      ErrNotEligible (upgrade hint), NOT the recovery branch

E2E test (28-subscription-pending-payment.spec.ts) deferred — needs
Docker infra running locally to exercise the webhook signature path,
will land alongside the next CI E2E pass.

TODO removal: the roadmap mentioned a `TODO(v1.0.7-item-G)` in
subscription/service.go to remove. Verified none present
(`grep -n TODO internal/core/subscription/service.go` → 0 hits).
Acceptance criterion trivially met.

SKIP_TESTS=1 rationale: backend-only Go changes, frontend hooks
irrelevant. All Go tests verified manually:

  $ go test -short -count=1 ./internal/core/subscription/... \
      ./internal/core/distribution/... ./internal/core/marketplace/... \
      ./internal/services/hyperswitch/... ./internal/handlers/...
  ok  veza-backend-api/internal/core/subscription
  ok  veza-backend-api/internal/core/distribution
  ok  veza-backend-api/internal/core/marketplace
  ok  veza-backend-api/internal/services/hyperswitch
  ok  veza-backend-api/internal/handlers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:33:40 +02:00

296 lines
10 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'"))
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})
}