First instalment of Item G from docs/audit-2026-04/v107-plan.md §G.
This commit lands the state machine + create-flow change. Phase 2
(webhook handler + recovery endpoint + reconciler sweep) follows.
What changes :
- **`models.go`** — adds `StatusPendingPayment` to the
SubscriptionStatus enum. Free-text VARCHAR(30) so no DDL needed
for the value itself; Phase 2's reconciler index lives in
migration 986 (additive, partial index on `created_at` WHERE
status='pending_payment').
- **`service.go`** — `PaymentProvider.CreateSubscriptionPayment`
interface gains an `idempotencyKey string` parameter, mirroring
the marketplace.refundProvider contract added in v1.0.7 item D.
Callers pass the new subscription row's UUID so a retried HTTP
request collapses to one PSP charge instead of duplicating it.
- **`createNewSubscription`** — refactored state machine :
* Free plan → StatusActive (unchanged, in subscribeToFreePlan).
* Paid plan, trial available, first-time user → StatusTrialing,
no PSP call (no invoice either — Phase 2 will create the
first paid invoice on trial expiry).
* Paid plan, no trial / repeat user → **StatusPendingPayment**
+ invoice + PSP CreateSubscriptionPayment with idempotency
key = subscription.ID.String(). Webhook
subscription.payment_succeeded (Phase 2) flips to active;
subscription.payment_failed flips to expired.
- **`if s.paymentProvider != nil` short-circuit removed**. Paid
plans now require a configured PaymentProvider — without one,
`createNewSubscription` returns ErrPaymentProviderRequired. The
handler maps this to HTTP 503 "Payment provider not configured —
paid plans temporarily unavailable", surfacing env misconfig to
ops instead of silently giving away paid plans (the v1.0.6.2
fantôme bug class).
- **`GetUserSubscription` query unchanged** — already filters on
`status IN ('active','trialing')`, so pending_payment rows
correctly read as "no active subscription" for feature-gate
purposes. The v1.0.6.2 hasEffectivePayment filter is kept as
defence-in-depth for legacy rows.
- **`hyperswitch.Provider`** — implements
`subscription.PaymentProvider` by delegating to the existing
`CreatePaymentSimple`. Compile-time interface assertion added
(`var _ subscription.PaymentProvider = (*Provider)(nil)`).
- **`routes_subscription.go`** — wires the Hyperswitch provider
into `subscription.NewService` when HyperswitchEnabled +
HyperswitchAPIKey + HyperswitchURL are all set. Without those,
the service falls back to no-provider mode (paid subscribes
return 503).
- **Tests** : new TestSubscribe_PendingPaymentStateMachine in
gate_test.go covers all five visible outcomes (free / paid+
provider / paid+no-provider / first-trial / repeat-trial) with a
fakePaymentProvider that records calls. Asserts on idempotency
key = subscription.ID.String(), PSP call counts, and the
Subscribe response shape (client_secret + payment_id surfaced).
5/5 green, sqlite :memory:.
Phase 2 backlog (next session) :
- `ProcessSubscriptionWebhook(ctx, payload)` — flip pending_payment
→ active on success / expired on failure, idempotent against
replays.
- Recovery endpoint `POST /api/v1/subscriptions/complete/:id` —
return the existing client_secret to resume a stalled flow.
- Reconciliation sweep for rows stuck in pending_payment past the
webhook-arrival window (uses the new partial index from
migration 986).
- Distribution.checkEligibility explicit pending_payment branch
(today it's already handled implicitly via the active/trialing
filter).
- E2E @critical : POST /subscribe → POST /distribution/submit
asserts 403 with "complete payment" until webhook fires.
Backward compat : clients on the previous flow that called
/subscribe expecting an immediately-active row will now see
status=pending_payment + a client_secret. They must drive the PSP
confirm step before the row is granted feature access. The
v1.0.6.2 voided_subscriptions cleanup migration (980) handles
pre-existing fantôme rows.
go build ./... clean. Subscription + handlers test suites green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
247 lines
8.1 KiB
Go
247 lines
8.1 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)
|
|
}
|
|
|
|
// 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})
|
|
}
|