veza/veza-backend-api/internal/core/subscription/service.go
senke 9a8d2a4e73 chore(release): v1.0.6.2 — subscription payment-gate bypass hotfix
Closes a bypass surfaced by the 2026-04 audit probe (axis-1 Q2): any
authenticated user could POST /api/v1/subscriptions/subscribe on a paid
plan and receive 201 active without the payment provider ever being
invoked. The resulting row satisfied `checkEligibility()` in the
distribution service via `can_sell_on_marketplace=true` on the Creator
plan — effectively free access to /api/v1/distribution/submit, which
dispatches to external partners.

Fix is centralised in `GetUserSubscription` so there is no code path
that can grant subscription-gated access without routing through the
payment check. Effective-payment = free plan OR unexpired trial OR
invoice with non-empty hyperswitch_payment_id. Migration 980 sweeps
pre-existing fantôme rows into `expired`, preserving the tuple in a
dated audit table for support outreach.

Subscribe and subscribeToFreePlan treat the new ErrSubscriptionNoPayment
as equivalent to ErrNoActiveSubscription so re-subscription works
cleanly post-cleanup. GET /me/subscription surfaces needs_payment=true
with a support-contact message rather than a misleading "you're on
free" or an opaque 500. TODO(v1.0.7-item-G) annotation marks where the
`if s.paymentProvider != nil` short-circuit needs to become a mandatory
pending_payment state.

Probe script `scripts/probes/subscription-unpaid-activation.sh` kept as
a versioned regression test — dry-run by default, --destructive logs in
and attempts the exploit against a live backend with automatic cleanup.
8-case unit test matrix covers the full hasEffectivePayment predicate.

Smoke validated end-to-end against local v1.0.6.2: POST /subscribe
returns 201 (by design — item G closes the creation path), but
GET /me/subscription returns subscription=null + needs_payment=true,
distribution eligibility returns false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:21:53 +02:00

596 lines
19 KiB
Go

package subscription
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Service errors
var (
ErrPlanNotFound = errors.New("subscription plan not found")
ErrSubscriptionNotFound = errors.New("subscription not found")
ErrAlreadySubscribed = errors.New("user already has an active subscription to this plan")
ErrCannotDowngradeDuring = errors.New("downgrade takes effect at end of current period")
ErrNoActiveSubscription = errors.New("no active subscription found")
ErrInvalidBillingCycle = errors.New("invalid billing cycle: must be 'monthly' or 'yearly'")
ErrFreePlanNoBilling = errors.New("free plan does not require billing")
// ErrSubscriptionNoPayment: a subscription row exists in active/trialing but no
// effective payment signal is attached (no PSP payment intent on any invoice, and
// neither free plan nor active trial). Introduced in v1.0.6.2 to close a bypass
// where rows could be created as 'active' without the payment provider ever
// being invoked (e.g., HYPERSWITCH_ENABLED=false). Callers that gate features
// by subscription should treat this as ineligible. The /me/subscription
// handler surfaces a specific message so honest-path users know to contact
// support.
ErrSubscriptionNoPayment = errors.New("subscription has no effective payment linkage")
)
// PaymentProvider defines the interface for subscription payments
type PaymentProvider interface {
CreateSubscriptionPayment(ctx context.Context, amountCents int, currency, subscriptionID, returnURL string, metadata map[string]string) (paymentID, clientSecret string, err error)
GetPayment(ctx context.Context, paymentID string) (status string, err error)
}
// ServiceOption is a functional option for configuring the Service
type ServiceOption func(*Service)
// WithPaymentProvider sets the payment provider for the subscription service
func WithPaymentProvider(p PaymentProvider) ServiceOption {
return func(s *Service) {
s.paymentProvider = p
}
}
// Service handles subscription business logic
type Service struct {
db *gorm.DB
logger *zap.Logger
paymentProvider PaymentProvider
}
// NewService creates a new subscription service
func NewService(db *gorm.DB, logger *zap.Logger, opts ...ServiceOption) *Service {
s := &Service{
db: db,
logger: logger,
}
for _, opt := range opts {
opt(s)
}
return s
}
// ListPlans returns all active subscription plans ordered by sort_order
func (s *Service) ListPlans(ctx context.Context) ([]Plan, error) {
var plans []Plan
if err := s.db.WithContext(ctx).
Where("is_active = ?", true).
Order("sort_order ASC").
Find(&plans).Error; err != nil {
return nil, fmt.Errorf("failed to list plans: %w", err)
}
return plans, nil
}
// GetPlan returns a plan by ID
func (s *Service) GetPlan(ctx context.Context, planID uuid.UUID) (*Plan, error) {
var plan Plan
if err := s.db.WithContext(ctx).First(&plan, "id = ?", planID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPlanNotFound
}
return nil, fmt.Errorf("failed to get plan: %w", err)
}
return &plan, nil
}
// GetPlanByName returns a plan by its name
func (s *Service) GetPlanByName(ctx context.Context, name PlanName) (*Plan, error) {
var plan Plan
if err := s.db.WithContext(ctx).First(&plan, "name = ?", string(name)).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPlanNotFound
}
return nil, fmt.Errorf("failed to get plan by name: %w", err)
}
return &plan, nil
}
// GetUserSubscription returns the user's current active/trialing subscription
// IFF it has an effective payment linkage. A row that sits in active/trialing
// but was never linked to a PSP payment intent (and is not on a free plan or
// in an unexpired trial) returns ErrSubscriptionNoPayment. This is the sole
// gate for feature eligibility — any caller routing to a paid-feature check
// goes through here, by design, so there is no code path that can grant
// access to a subscription that never paid. See v1.0.6.2 hotfix.
func (s *Service) GetUserSubscription(ctx context.Context, userID uuid.UUID) (*UserSubscription, error) {
var sub UserSubscription
err := s.db.WithContext(ctx).
Preload("Plan").
Where("user_id = ? AND status IN ?", userID, []string{string(StatusActive), string(StatusTrialing)}).
First(&sub).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveSubscription
}
return nil, fmt.Errorf("failed to get user subscription: %w", err)
}
if !s.hasEffectivePayment(ctx, &sub) {
return nil, ErrSubscriptionNoPayment
}
return &sub, nil
}
// hasEffectivePayment returns true if the subscription represents a legitimate
// claim on paid features. True when: the plan is free, OR the subscription is
// in an unexpired trial, OR at least one invoice carries a PSP payment intent
// (hyperswitch_payment_id populated). The last branch raises the bar from
// "anyone can POST /subscribe" to "someone must have actually reached the
// PSP". It does not prove the charge succeeded — item G in v1.0.7 tightens
// this further by requiring invoice.status='paid' via webhook.
func (s *Service) hasEffectivePayment(ctx context.Context, sub *UserSubscription) bool {
if sub.Plan.PriceMonthly == 0 {
return true
}
if sub.Status == StatusTrialing && sub.TrialEnd != nil && time.Now().Before(*sub.TrialEnd) {
return true
}
var count int64
s.db.WithContext(ctx).Model(&Invoice{}).
Where("subscription_id = ? AND hyperswitch_payment_id IS NOT NULL AND hyperswitch_payment_id <> ''", sub.ID).
Count(&count)
return count > 0
}
// GetUserSubscriptionHistory returns all subscriptions for a user (including canceled/expired)
func (s *Service) GetUserSubscriptionHistory(ctx context.Context, userID uuid.UUID, limit, offset int) ([]UserSubscription, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
var subs []UserSubscription
err := s.db.WithContext(ctx).
Preload("Plan").
Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&subs).Error
if err != nil {
return nil, fmt.Errorf("failed to get subscription history: %w", err)
}
return subs, nil
}
// SubscribeRequest holds the parameters for subscribing to a plan
type SubscribeRequest struct {
PlanID uuid.UUID `json:"plan_id" binding:"required"`
BillingCycle BillingCycle `json:"billing_cycle" binding:"required"`
}
// SubscribeResponse holds the result of a subscription creation
type SubscribeResponse struct {
Subscription *UserSubscription `json:"subscription"`
ClientSecret string `json:"client_secret,omitempty"` // For Hyperswitch payment
PaymentID string `json:"payment_id,omitempty"`
}
// Subscribe creates a new subscription for a user
func (s *Service) Subscribe(ctx context.Context, userID uuid.UUID, req SubscribeRequest) (*SubscribeResponse, error) {
if req.BillingCycle != BillingMonthly && req.BillingCycle != BillingYearly {
return nil, ErrInvalidBillingCycle
}
plan, err := s.GetPlan(ctx, req.PlanID)
if err != nil {
return nil, err
}
if plan.Name == PlanFree {
return s.subscribeToFreePlan(ctx, userID, plan)
}
// Check for existing active subscription. A row in active/trialing without
// effective payment (ErrSubscriptionNoPayment, v1.0.6.2) is treated as
// "no active subscription" for the purpose of re-subscribing — the
// cleanup migration 980 voids those rows at deploy time, so in practice
// this branch is only hit during the brief deploy window.
existing, err := s.GetUserSubscription(ctx, userID)
if err != nil && !errors.Is(err, ErrNoActiveSubscription) && !errors.Is(err, ErrSubscriptionNoPayment) {
return nil, err
}
if errors.Is(err, ErrSubscriptionNoPayment) {
existing = nil
}
if existing != nil && existing.PlanID == req.PlanID {
return nil, ErrAlreadySubscribed
}
// If upgrading from a lower plan, handle the transition
if existing != nil {
return s.changePlan(ctx, userID, existing, plan, req.BillingCycle)
}
return s.createNewSubscription(ctx, userID, plan, req.BillingCycle)
}
// subscribeToFreePlan assigns the free plan without payment
func (s *Service) subscribeToFreePlan(ctx context.Context, userID uuid.UUID, plan *Plan) (*SubscribeResponse, error) {
// Cancel any existing subscription first. A no-payment fantôme row
// (v1.0.6.2 filter) is treated as "nothing to cancel" — the cleanup
// migration handles it at deploy time.
existing, err := s.GetUserSubscription(ctx, userID)
if err != nil && !errors.Is(err, ErrNoActiveSubscription) && !errors.Is(err, ErrSubscriptionNoPayment) {
return nil, err
}
if existing != nil {
if err := s.cancelImmediately(ctx, existing); err != nil {
return nil, err
}
}
now := time.Now()
sub := &UserSubscription{
UserID: userID,
PlanID: plan.ID,
Status: StatusActive,
BillingCycle: BillingMonthly,
CurrentPeriodStart: now,
CurrentPeriodEnd: now.AddDate(100, 0, 0), // effectively never expires
}
if err := s.db.WithContext(ctx).Create(sub).Error; err != nil {
return nil, fmt.Errorf("failed to create free subscription: %w", err)
}
sub.Plan = *plan
return &SubscribeResponse{Subscription: sub}, nil
}
// createNewSubscription creates a subscription for a paid plan
func (s *Service) createNewSubscription(ctx context.Context, userID uuid.UUID, plan *Plan, cycle BillingCycle) (*SubscribeResponse, error) {
now := time.Now()
var periodEnd time.Time
var amountCents int
switch cycle {
case BillingYearly:
periodEnd = now.AddDate(1, 0, 0)
amountCents = plan.PriceYearly
default:
periodEnd = now.AddDate(0, 1, 0)
amountCents = plan.PriceMonthly
}
sub := &UserSubscription{
UserID: userID,
PlanID: plan.ID,
BillingCycle: cycle,
CurrentPeriodStart: now,
CurrentPeriodEnd: periodEnd,
}
var clientSecret, paymentID string
// SECURITY(REM-015): Trial check + subscription creation in single transaction to prevent
// race condition where two concurrent requests both see previousTrialCount=0.
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Apply trial if available — checked INSIDE transaction for atomicity
if plan.TrialDays > 0 {
var previousTrialCount int64
tx.Model(&UserSubscription{}).
Where("user_id = ? AND trial_start IS NOT NULL", userID).
Count(&previousTrialCount)
if previousTrialCount > 0 {
sub.Status = StatusActive
} else {
trialEnd := now.AddDate(0, 0, plan.TrialDays)
sub.Status = StatusTrialing
sub.TrialStart = &now
sub.TrialEnd = &trialEnd
sub.CurrentPeriodEnd = trialEnd
}
} else {
sub.Status = StatusActive
}
if err := tx.Create(sub).Error; err != nil {
return fmt.Errorf("failed to create subscription: %w", err)
}
// Create invoice (for paid plans, not during trial)
if !sub.IsTrialing() && amountCents > 0 {
invoice := &Invoice{
SubscriptionID: sub.ID,
UserID: userID,
AmountCents: amountCents,
Currency: plan.Currency,
Status: InvoicePending,
BillingPeriodStart: now,
BillingPeriodEnd: periodEnd,
}
if err := tx.Create(invoice).Error; err != nil {
return fmt.Errorf("failed to create invoice: %w", err)
}
// TODO(v1.0.7-item-G): make payment provider mandatory for paid plans.
// Today `if s.paymentProvider != nil` short-circuits silently when
// Hyperswitch is disabled, leaving the row `active` with no PSP
// linkage. Item G replaces this with a mandatory pending_payment
// state + webhook-driven activation. Until then, v1.0.6.2
// compensates via the `GetUserSubscription` filter.
if s.paymentProvider != nil {
var err error
paymentID, clientSecret, err = s.paymentProvider.CreateSubscriptionPayment(
ctx, amountCents, plan.Currency, sub.ID.String(),
"", // returnURL to be set by frontend
map[string]string{
"user_id": userID.String(),
"subscription_id": sub.ID.String(),
"plan": string(plan.Name),
"billing_cycle": string(cycle),
},
)
if err != nil {
return fmt.Errorf("failed to create payment: %w", err)
}
invoice.HyperswitchPaymentID = paymentID
if err := tx.Save(invoice).Error; err != nil {
return fmt.Errorf("failed to update invoice with payment ID: %w", err)
}
}
}
return nil
})
if err != nil {
return nil, err
}
sub.Plan = *plan
return &SubscribeResponse{
Subscription: sub,
ClientSecret: clientSecret,
PaymentID: paymentID,
}, nil
}
// changePlan handles upgrade or downgrade between plans
func (s *Service) changePlan(ctx context.Context, userID uuid.UUID, current *UserSubscription, newPlan *Plan, cycle BillingCycle) (*SubscribeResponse, error) {
currentPlan := &current.Plan
if currentPlan.ID == uuid.Nil {
var err error
currentPlan, err = s.GetPlan(ctx, current.PlanID)
if err != nil {
return nil, err
}
}
isUpgrade := newPlan.SortOrder > currentPlan.SortOrder
if isUpgrade {
// Upgrade: takes effect immediately
return s.upgradeSubscription(ctx, userID, current, newPlan, cycle)
}
// Downgrade: takes effect at end of current period
return s.scheduleDowngrade(ctx, userID, current, newPlan, cycle)
}
// upgradeSubscription applies an immediate upgrade
func (s *Service) upgradeSubscription(ctx context.Context, userID uuid.UUID, current *UserSubscription, newPlan *Plan, cycle BillingCycle) (*SubscribeResponse, error) {
now := time.Now()
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Expire the current subscription
current.Status = StatusExpired
if err := tx.Save(current).Error; err != nil {
return fmt.Errorf("failed to expire current subscription: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
s.logger.Info("User upgraded subscription",
zap.String("user_id", userID.String()),
zap.String("from_plan", string(current.Plan.Name)),
zap.String("to_plan", string(newPlan.Name)),
zap.Time("upgraded_at", now),
)
return s.createNewSubscription(ctx, userID, newPlan, cycle)
}
// scheduleDowngrade schedules a downgrade at end of current period
func (s *Service) scheduleDowngrade(ctx context.Context, userID uuid.UUID, current *UserSubscription, newPlan *Plan, cycle BillingCycle) (*SubscribeResponse, error) {
s.logger.Info("User scheduled downgrade",
zap.String("user_id", userID.String()),
zap.String("from_plan", string(current.Plan.Name)),
zap.String("to_plan", string(newPlan.Name)),
zap.Time("effective_at", current.CurrentPeriodEnd),
)
// For now, we mark the current as cancel_at_period_end and return info
// The actual downgrade will happen when the period ends (via ProcessExpiredSubscriptions)
current.CancelAtPeriodEnd = true
now := time.Now()
current.CanceledAt = &now
if err := s.db.WithContext(ctx).Save(current).Error; err != nil {
return nil, fmt.Errorf("failed to schedule downgrade: %w", err)
}
current.Plan = *newPlan // indicate the target plan in response
return &SubscribeResponse{
Subscription: current,
}, nil
}
// CancelSubscription cancels a user's subscription at the end of the current period
func (s *Service) CancelSubscription(ctx context.Context, userID uuid.UUID) (*UserSubscription, error) {
sub, err := s.GetUserSubscription(ctx, userID)
if err != nil {
return nil, err
}
// Free plan: cancel immediately
if sub.Plan.Name == PlanFree {
return nil, ErrFreePlanNoBilling
}
now := time.Now()
sub.CancelAtPeriodEnd = true
sub.CanceledAt = &now
if err := s.db.WithContext(ctx).Save(sub).Error; err != nil {
return nil, fmt.Errorf("failed to cancel subscription: %w", err)
}
s.logger.Info("User canceled subscription",
zap.String("user_id", userID.String()),
zap.String("plan", string(sub.Plan.Name)),
zap.Time("access_until", sub.CurrentPeriodEnd),
)
return sub, nil
}
// ReactivateSubscription removes the cancellation flag if still within the period
func (s *Service) ReactivateSubscription(ctx context.Context, userID uuid.UUID) (*UserSubscription, error) {
sub, err := s.GetUserSubscription(ctx, userID)
if err != nil {
return nil, err
}
if !sub.CancelAtPeriodEnd {
return sub, nil // not canceled, nothing to do
}
sub.CancelAtPeriodEnd = false
sub.CanceledAt = nil
if err := s.db.WithContext(ctx).Save(sub).Error; err != nil {
return nil, fmt.Errorf("failed to reactivate subscription: %w", err)
}
s.logger.Info("User reactivated subscription",
zap.String("user_id", userID.String()),
zap.String("plan", string(sub.Plan.Name)),
)
return sub, nil
}
// cancelImmediately expires a subscription right away (used internally for plan switches)
func (s *Service) cancelImmediately(ctx context.Context, sub *UserSubscription) error {
now := time.Now()
sub.Status = StatusExpired
sub.CanceledAt = &now
return s.db.WithContext(ctx).Save(sub).Error
}
// GetUserInvoices returns invoices for a user
func (s *Service) GetUserInvoices(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Invoice, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
var invoices []Invoice
err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&invoices).Error
if err != nil {
return nil, fmt.Errorf("failed to get invoices: %w", err)
}
return invoices, nil
}
// ProcessExpiredSubscriptions checks for subscriptions past their period end and expires them
// This should be called periodically (e.g., daily cron job)
func (s *Service) ProcessExpiredSubscriptions(ctx context.Context) (int, error) {
now := time.Now()
var count int64
// Expire subscriptions that have cancel_at_period_end and period has ended
result := s.db.WithContext(ctx).
Model(&UserSubscription{}).
Where("cancel_at_period_end = ? AND current_period_end < ? AND status IN ?",
true, now, []string{string(StatusActive), string(StatusTrialing)}).
Updates(map[string]interface{}{
"status": StatusExpired,
"updated_at": now,
})
if result.Error != nil {
return 0, fmt.Errorf("failed to expire subscriptions: %w", result.Error)
}
count = result.RowsAffected
// Expire trials that have ended without payment
trialResult := s.db.WithContext(ctx).
Model(&UserSubscription{}).
Where("status = ? AND trial_end < ?", StatusTrialing, now).
Updates(map[string]interface{}{
"status": StatusExpired,
"updated_at": now,
})
if trialResult.Error != nil {
return int(count), fmt.Errorf("failed to expire trials: %w", trialResult.Error)
}
count += trialResult.RowsAffected
if count > 0 {
s.logger.Info("Processed expired subscriptions", zap.Int64("expired_count", count))
}
return int(count), nil
}
// ChangeBillingCycle switches between monthly and yearly billing
func (s *Service) ChangeBillingCycle(ctx context.Context, userID uuid.UUID, newCycle BillingCycle) (*UserSubscription, error) {
if newCycle != BillingMonthly && newCycle != BillingYearly {
return nil, ErrInvalidBillingCycle
}
sub, err := s.GetUserSubscription(ctx, userID)
if err != nil {
return nil, err
}
if sub.BillingCycle == newCycle {
return sub, nil // already on this cycle
}
sub.BillingCycle = newCycle
// The new cycle takes effect at the next renewal
if err := s.db.WithContext(ctx).Save(sub).Error; err != nil {
return nil, fmt.Errorf("failed to change billing cycle: %w", err)
}
s.logger.Info("User changed billing cycle",
zap.String("user_id", userID.String()),
zap.String("new_cycle", string(newCycle)),
)
return sub, nil
}