veza/veza-backend-api/internal/core/subscription/models.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

157 lines
6.7 KiB
Go

package subscription
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// PlanName represents a subscription plan type
type PlanName string
const (
PlanFree PlanName = "free"
PlanCreator PlanName = "creator"
PlanPremium PlanName = "premium"
)
// SubscriptionStatus represents the status of a user subscription
type SubscriptionStatus string
const (
StatusActive SubscriptionStatus = "active"
StatusTrialing SubscriptionStatus = "trialing"
StatusCanceled SubscriptionStatus = "canceled"
StatusPastDue SubscriptionStatus = "past_due"
StatusExpired SubscriptionStatus = "expired"
// StatusPendingPayment (v1.0.9 item G) — paid-plan subscription
// row created but the PSP charge has not been confirmed yet.
// Webhook subscription.payment_succeeded → flip to active.
// Webhook subscription.payment_failed → flip to expired.
// Replaces the v1.0.6.2 "active row without PSP linkage" fantôme
// pattern that hasEffectivePayment had to filter retroactively.
StatusPendingPayment SubscriptionStatus = "pending_payment"
)
// BillingCycle represents the billing frequency
type BillingCycle string
const (
BillingMonthly BillingCycle = "monthly"
BillingYearly BillingCycle = "yearly"
)
// InvoiceStatus represents the status of an invoice
type InvoiceStatus string
const (
InvoicePending InvoiceStatus = "pending"
InvoicePaid InvoiceStatus = "paid"
InvoiceFailed InvoiceStatus = "failed"
InvoiceRefunded InvoiceStatus = "refunded"
)
// Plan represents a subscription plan definition
type Plan struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
Name PlanName `gorm:"not null;uniqueIndex;size:50" json:"name"`
DisplayName string `gorm:"not null;size:100" json:"display_name"`
Description string `gorm:"type:text" json:"description"`
PriceMonthly int `gorm:"column:price_monthly_cents;not null;default:0" json:"price_monthly_cents"`
PriceYearly int `gorm:"column:price_yearly_cents;not null;default:0" json:"price_yearly_cents"`
Currency string `gorm:"size:3;default:'USD'" json:"currency"`
UploadLimitMonthly *int `gorm:"column:upload_limit_monthly" json:"upload_limit_monthly"`
StorageLimitBytes int64 `gorm:"not null;default:0" json:"storage_limit_bytes"`
MarketplaceCommission float64 `gorm:"column:marketplace_commission_rate;type:numeric(5,4);default:0" json:"marketplace_commission_rate"`
CanSellOnMarketplace bool `gorm:"not null;default:false" json:"can_sell_on_marketplace"`
HasPrioritySupport bool `gorm:"not null;default:false" json:"has_priority_support"`
HasCollaborationTools bool `gorm:"not null;default:false" json:"has_collaboration_tools"`
HasDistribution bool `gorm:"not null;default:false" json:"has_distribution"`
TrialDays int `gorm:"not null;default:0" json:"trial_days"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Plan) TableName() string {
return "subscription_plans"
}
func (p *Plan) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
// UserSubscription represents a user's active or past subscription
type UserSubscription struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
PlanID uuid.UUID `gorm:"type:uuid;not null" json:"plan_id"`
Status SubscriptionStatus `gorm:"not null;size:30;default:'active'" json:"status"`
BillingCycle BillingCycle `gorm:"not null;size:10;default:'monthly'" json:"billing_cycle"`
CurrentPeriodStart time.Time `gorm:"not null" json:"current_period_start"`
CurrentPeriodEnd time.Time `gorm:"not null" json:"current_period_end"`
TrialStart *time.Time `json:"trial_start,omitempty"`
TrialEnd *time.Time `json:"trial_end,omitempty"`
CanceledAt *time.Time `json:"canceled_at,omitempty"`
CancelAtPeriodEnd bool `gorm:"not null;default:false" json:"cancel_at_period_end"`
HyperswitchSubscriptionID string `gorm:"size:255" json:"hyperswitch_subscription_id,omitempty"`
HyperswitchCustomerID string `gorm:"size:255" json:"hyperswitch_customer_id,omitempty"`
PaymentMethodID string `gorm:"size:255" json:"payment_method_id,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Relations (loaded via Preload)
Plan Plan `gorm:"foreignKey:PlanID" json:"plan,omitempty"`
}
func (UserSubscription) TableName() string {
return "user_subscriptions"
}
func (s *UserSubscription) BeforeCreate(tx *gorm.DB) error {
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
return nil
}
// IsTrialing returns true if the subscription is in trial period
func (s *UserSubscription) IsTrialing() bool {
return s.Status == StatusTrialing && s.TrialEnd != nil && time.Now().Before(*s.TrialEnd)
}
// IsActiveOrTrialing returns true if the subscription grants access
func (s *UserSubscription) IsActiveOrTrialing() bool {
return s.Status == StatusActive || s.Status == StatusTrialing
}
// Invoice represents a subscription billing invoice
type Invoice struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
SubscriptionID uuid.UUID `gorm:"type:uuid;not null" json:"subscription_id"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
AmountCents int `gorm:"not null" json:"amount_cents"`
Currency string `gorm:"size:3;default:'USD'" json:"currency"`
Status InvoiceStatus `gorm:"not null;size:30;default:'pending'" json:"status"`
BillingPeriodStart time.Time `gorm:"not null" json:"billing_period_start"`
BillingPeriodEnd time.Time `gorm:"not null" json:"billing_period_end"`
HyperswitchPaymentID string `gorm:"size:255" json:"hyperswitch_payment_id,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (Invoice) TableName() string {
return "subscription_invoices"
}
func (i *Invoice) BeforeCreate(tx *gorm.DB) error {
if i.ID == uuid.Nil {
i.ID = uuid.New()
}
return nil
}