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
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>
770 lines
27 KiB
Go
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 := ¤t.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
|
|
}
|