veza/veza-backend-api/internal/core/subscription/service.go
senke 7e26a8dd1f
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
feat(subscription): recovery endpoint + distribution gate (v1.0.9 item G — Phase 3)
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>
2026-04-27 11:33:40 +02:00

770 lines
27 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. Item G makes new code paths skip this state altogether by
// using StatusPendingPayment instead — but the filter is kept as
// defence-in-depth for legacy rows that pre-date the migration.
ErrSubscriptionNoPayment = errors.New("subscription has no effective payment linkage")
// ErrPaymentProviderRequired (v1.0.9 item G): a paid plan subscribe
// attempt was made without a configured PaymentProvider. v1.0.6.2
// silently let this through, leaving rows in `active` with no PSP
// linkage. Item G fail-closes — the handler maps this to HTTP 503
// "payment provider not configured" so an env misconfiguration is
// loud instead of silently giving away paid plans.
ErrPaymentProviderRequired = errors.New("paid plan requires a configured payment provider")
// ErrSubscriptionPendingPayment (v1.0.9 item G Phase 3): a paid-plan
// row exists in StatusPendingPayment — the user opened the subscribe
// flow but the webhook hasn't confirmed (succeeded/failed) yet, or
// the flow stalled on the frontend before payment confirmation.
// Distinct from ErrNoActiveSubscription so the UX can prompt the
// user to RESUME the existing payment via POST /subscriptions/
// complete/:id (which re-fetches the client_secret) instead of
// starting a fresh subscribe that would create a duplicate row.
ErrSubscriptionPendingPayment = errors.New("subscription has a pending payment — complete it to activate")
// ErrSubscriptionNotPending (v1.0.9 item G Phase 3): the
// CompletePendingPayment recovery endpoint was called against a
// subscription that is NOT in StatusPendingPayment. Surfaced as
// HTTP 409 so the frontend doesn't silently swap out an active row.
ErrSubscriptionNotPending = errors.New("subscription is not in pending_payment state")
)
// PaymentProvider defines the interface for subscription payments.
//
// idempotencyKey (v1.0.9 item G) — must be unique per logical
// subscription creation; passed through to the PSP's `Idempotency-Key`
// HTTP header. Callers pass the new subscription row's UUID so a
// retried HTTP request from the same Subscribe() call collapses to one
// PSP charge. Empty key MUST cause a loud failure rather than a silent
// header omission, mirroring the marketplace.refundProvider contract.
type PaymentProvider interface {
CreateSubscriptionPayment(ctx context.Context, idempotencyKey string, 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
}
// GetPendingPaymentSubscription returns the most-recently-created
// subscription row for the user that is still in StatusPendingPayment,
// preloading Plan. Returns ErrNoActiveSubscription when no such row
// exists (callers chain after a GetUserSubscription miss to detect the
// "stalled payment" case). v1.0.9 item G Phase 3.
func (s *Service) GetPendingPaymentSubscription(ctx context.Context, userID uuid.UUID) (*UserSubscription, error) {
var sub UserSubscription
err := s.db.WithContext(ctx).
Preload("Plan").
Where("user_id = ? AND status = ?", userID, StatusPendingPayment).
Order("created_at DESC").
First(&sub).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveSubscription
}
return nil, fmt.Errorf("failed to get pending_payment subscription: %w", err)
}
return &sub, nil
}
// CompletePendingPayment re-fetches the PSP client_secret for a
// subscription stuck in StatusPendingPayment so the frontend can drive
// the payment UI to completion. The PSP CreateSubscriptionPayment call
// is idempotent on the subscription ID (item G + item D pattern), so
// hitting this endpoint repeatedly returns the same payment intent
// rather than creating duplicates.
//
// Errors:
// - ErrSubscriptionNotFound: subID does not exist or does not belong to userID
// - ErrSubscriptionNotPending: subscription is NOT in pending_payment
// (already activated by webhook, expired, canceled, etc.)
// - ErrPaymentProviderRequired: HYPERSWITCH_ENABLED=false or provider missing
//
// Idempotency safety: the PSP returns the same payment_id for the same
// idempotency key (sub.ID.String()), so this endpoint is safe to retry
// from the frontend on transient network failures.
func (s *Service) CompletePendingPayment(ctx context.Context, userID, subscriptionID uuid.UUID) (*SubscribeResponse, error) {
var sub UserSubscription
if err := s.db.WithContext(ctx).
Preload("Plan").
Where("id = ? AND user_id = ?", subscriptionID, userID).
First(&sub).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSubscriptionNotFound
}
return nil, fmt.Errorf("failed to get subscription: %w", err)
}
if sub.Status != StatusPendingPayment {
return nil, ErrSubscriptionNotPending
}
if s.paymentProvider == nil {
return nil, ErrPaymentProviderRequired
}
// Resolve the amount from the latest invoice (Phase 1 created one
// with the same period bounds; using its row keeps amount + currency
// authoritative even if the plan price has changed since).
var inv Invoice
if err := s.db.WithContext(ctx).
Where("subscription_id = ?", sub.ID).
Order("created_at DESC").
First(&inv).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Defensive: a pending_payment row WITHOUT an invoice is a
// data integrity issue (Phase 1 always creates the pair).
// Fall back to plan pricing rather than 500-ing.
return nil, fmt.Errorf("subscription %s in pending_payment without invoice", sub.ID)
}
return nil, fmt.Errorf("failed to get invoice: %w", err)
}
paymentID, clientSecret, psErr := s.paymentProvider.CreateSubscriptionPayment(
ctx,
sub.ID.String(), // idempotency key — same as Phase 1 so the PSP returns the existing intent
inv.AmountCents,
inv.Currency,
sub.ID.String(),
"",
map[string]string{
"user_id": userID.String(),
"subscription_id": sub.ID.String(),
"plan": string(sub.Plan.Name),
"billing_cycle": string(sub.BillingCycle),
"recovery": "true",
},
)
if psErr != nil {
return nil, fmt.Errorf("failed to recover payment intent: %w", psErr)
}
// Persist payment_id if it changed (defensive — idempotent calls
// should return the same id, but the PSP may have rotated for any
// reason and the webhook handler relies on this column to dispatch).
if inv.HyperswitchPaymentID != paymentID {
s.logger.Warn("CompletePendingPayment: PSP returned a different payment_id than recorded; updating invoice",
zap.String("subscription_id", sub.ID.String()),
zap.String("old_payment_id", inv.HyperswitchPaymentID),
zap.String("new_payment_id", paymentID))
if err := s.db.WithContext(ctx).
Model(&Invoice{}).
Where("id = ?", inv.ID).
Updates(map[string]any{"hyperswitch_payment_id": paymentID}).Error; err != nil {
return nil, fmt.Errorf("failed to update invoice payment_id: %w", err)
}
}
return &SubscribeResponse{
Subscription: &sub,
ClientSecret: clientSecret,
PaymentID: paymentID,
}, nil
}
// 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.
//
// v1.0.9 item G — state machine:
// - Trial available + first-time user → status=trialing, no PSP call,
// no invoice. Trial expiry will require a follow-up flow that
// creates the first paid invoice and transitions the row.
// - Trial available + repeat user (already used trial) → falls
// through to the paid-plan branch with status=pending_payment.
// - Paid plan, no trial / repeat → status=pending_payment, invoice
// created with PSP payment_id, client_secret returned for the
// frontend to drive the payment UI. Webhook
// subscription.payment_succeeded flips to active. Webhook
// subscription.payment_failed flips to expired.
//
// PaymentProvider is now mandatory for any paid-plan subscribe path
// that hits the PSP (replaces the v1.0.6.2 silent short-circuit). The
// handler maps ErrPaymentProviderRequired to HTTP 503 so misconfig is
// surfaced to ops, not silently absorbed.
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.
// First-time trial users get StatusTrialing (no immediate PSP call).
// Repeat users (or no trial offered) fall through to the paid path
// which creates the row in StatusPendingPayment.
var inTrial bool
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 {
trialEnd := now.AddDate(0, 0, plan.TrialDays)
sub.Status = StatusTrialing
sub.TrialStart = &now
sub.TrialEnd = &trialEnd
sub.CurrentPeriodEnd = trialEnd
inTrial = true
}
}
if !inTrial {
// Paid plan, no trial → row enters pending_payment state.
// Will transition to active on subscription.payment_succeeded
// webhook, or to expired on subscription.payment_failed.
sub.Status = StatusPendingPayment
}
if err := tx.Create(sub).Error; err != nil {
return fmt.Errorf("failed to create subscription: %w", err)
}
// Create invoice + open PSP charge (paid plans not in trial).
if !inTrial && amountCents > 0 {
// v1.0.9 item G: payment provider is mandatory here. The
// previous silent `if s.paymentProvider != nil` branch left
// the row in `active` without PSP linkage when Hyperswitch
// was disabled — effectively giving the plan away.
if s.paymentProvider == nil {
return ErrPaymentProviderRequired
}
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)
}
var psErr error
paymentID, clientSecret, psErr = s.paymentProvider.CreateSubscriptionPayment(
ctx,
sub.ID.String(), // idempotency key (item G + item D pattern)
amountCents,
plan.Currency,
sub.ID.String(),
"", // returnURL — the frontend sets it on the confirm step
map[string]string{
"user_id": userID.String(),
"subscription_id": sub.ID.String(),
"plan": string(plan.Name),
"billing_cycle": string(cycle),
},
)
if psErr != nil {
return fmt.Errorf("failed to create payment: %w", psErr)
}
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
}