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
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>
296 lines
10 KiB
Go
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})
|
|
}
|