veza/veza-backend-api/internal/api/routes_subscription.go
senke 2a96766ae3 feat(subscription): pending_payment state machine + mandatory provider (v1.0.9 item G — Phase 1)
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>
2026-04-26 10:02:00 +02:00

47 lines
1.8 KiB
Go

package api
import (
"github.com/gin-gonic/gin"
"veza-backend-api/internal/core/subscription"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/services/hyperswitch"
)
// setupSubscriptionRoutes configures routes for subscription plans management (v0.12.1).
//
// v1.0.9 item G — when Hyperswitch is configured, the subscription
// service is built with a PaymentProvider. Without it, paid-plan
// subscribe attempts fail with HTTP 503 "payment provider not
// configured" (replaces the v1.0.6.2 silent fantôme creation).
func (r *APIRouter) setupSubscriptionRoutes(router *gin.RouterGroup) {
opts := []subscription.ServiceOption{}
if r.config.HyperswitchEnabled && r.config.HyperswitchAPIKey != "" && r.config.HyperswitchURL != "" {
hsClient := hyperswitch.NewClient(r.config.HyperswitchURL, r.config.HyperswitchAPIKey)
hsProvider := hyperswitch.NewProvider(hsClient)
opts = append(opts, subscription.WithPaymentProvider(hsProvider))
}
svc := subscription.NewService(r.db.GormDB, r.logger, opts...)
handler := handlers.NewSubscriptionHandler(svc, r.logger)
group := router.Group("/subscriptions")
// Public: list available plans
group.GET("/plans", handler.ListPlans)
group.GET("/plans/:id", handler.GetPlan)
// Protected: user subscription management
if r.config.AuthMiddleware != nil {
protected := group.Group("")
protected.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(protected)
protected.GET("/me", handler.GetMySubscription)
protected.POST("/subscribe", handler.Subscribe)
protected.POST("/cancel", handler.CancelSubscription)
protected.POST("/reactivate", handler.ReactivateSubscription)
protected.PUT("/billing-cycle", handler.ChangeBillingCycle)
protected.GET("/invoices", handler.GetInvoices)
protected.GET("/history", handler.GetSubscriptionHistory)
}
}