subscription

package
v0.0.0-...-6067653 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2025 License: Apache-2.0 Imports: 13 Imported by: 0

README

Subscription

SaaS subscription management with resource limits, feature flags, and billing provider integration.

Features

  • Resource Limits - Enforce usage limits for countable resources (users, projects, API calls, etc.)
  • Feature Flags - Control access to features based on subscription plan
  • Billing Integration - Provider-agnostic integration with Paddle, Stripe, or Lemonsqueezy
  • Trial Management - Built-in trial period handling with automatic expiration

Installation

import "github.com/dmitrymomot/saaskit/pkg/subscription"

Usage

// Define plans
plans := []subscription.Plan{
    {
        ID:       "free",
        Name:     "Free",
        Interval: subscription.BillingIntervalNone,
        Limits: map[subscription.Resource]int64{
            subscription.ResourceUsers:    1,
            subscription.ResourceProjects: 3,
        },
    },
    {
        ID:       "price_pro_monthly", // Provider's price ID
        Name:     "Professional",
        Interval: subscription.BillingIntervalMonthly,
        Price:    subscription.Money{Amount: 9900, Currency: "USD"},
        Limits: map[subscription.Resource]int64{
            subscription.ResourceUsers:    subscription.Unlimited,
            subscription.ResourceProjects: subscription.Unlimited,
        },
        Features: []subscription.Feature{
            subscription.FeatureAI,
            subscription.FeatureSSO,
        },
        TrialDays: 14,
    },
}

// Create service
svc, err := subscription.NewService(
    ctx,
    subscription.NewInMemSource(plans...),
    paddleProvider, // Your BillingProvider implementation
    store,          // Your SubscriptionStore implementation
    // Register resource counters (panic if duplicate)
    subscription.WithCounter(subscription.ResourceUsers, userCounter),
    subscription.WithCounter(subscription.ResourceProjects, projectCounter),
)

Common Operations

Check Resource Limits
// Check if user can create a resource
err := svc.CanCreate(ctx, tenantID, subscription.ResourceUsers)
if errors.Is(err, subscription.ErrLimitExceeded) {
    // Show upgrade prompt
}

// Get current usage
used, limit, err := svc.GetUsage(ctx, tenantID, subscription.ResourceProjects)
Check Feature Access
// Check if feature is available
if svc.HasFeature(ctx, tenantID, subscription.FeatureAI) {
    // Enable AI features
}
Handle Subscriptions
// Create checkout session for paid plan
link, err := svc.CreateCheckoutLink(ctx, tenantID, "price_pro_monthly",
    subscription.CheckoutOptions{
        Email:      "[email protected]",
        SuccessURL: "https://app.com/success",
        CancelURL:  "https://app.com/cancel",
    },
)
// Redirect to link.URL

// Handle webhook from provider (in HTTP handler)
err = svc.HandleWebhook(r) // r is *http.Request

// Get customer portal link
portal, err := svc.GetCustomerPortalLink(ctx, tenantID)
// Redirect to portal.URL

Error Handling

// Package errors:
var (
    ErrPlanNotFound        = errors.New("subscription plan not found")
    ErrLimitExceeded       = errors.New("subscription limit exceeded")
    ErrNoCounterRegistered = errors.New("no usage counter registered for resource")
    ErrTrialExpired        = errors.New("subscription trial has expired")
    ErrSubscriptionNotFound = errors.New("subscription not found")
)

// Usage:
if errors.Is(err, subscription.ErrLimitExceeded) {
    // handle limit exceeded
}

Configuration

// Implement resource counters
func userCounter(ctx context.Context, tenantID uuid.UUID) (int64, error) {
    return db.CountUsers(ctx, tenantID)
}

// Custom plan ID resolver (default uses context)
func customResolver(ctx context.Context, tenantID uuid.UUID) (string, error) {
    return db.GetPlanID(ctx, tenantID)
}

svc, err := subscription.NewService(
    ctx, plansSource, provider, store,
    subscription.WithPlanIDResolver(customResolver),
)

API Documentation

# Full API documentation
go doc github.com/dmitrymomot/saaskit/pkg/subscription

# Specific function or type
go doc github.com/dmitrymomot/saaskit/pkg/subscription.Service

Notes

  • Resource counters must be fast (use caching or database aggregates)
  • Plan IDs should match your payment provider's price IDs for paid plans
  • Registering the same resource counter twice will panic - this is intentional to prevent configuration errors

Documentation

Overview

Package subscription provides comprehensive SaaS subscription management with resource limits, feature flags, trial periods, and billing provider integration.

The package implements a flexible subscription system that enforces usage limits, controls feature access, and integrates with payment providers (Paddle, Stripe, Lemonsqueezy) through a minimal interface. It's designed for solo developers building SaaS applications who need a pragmatic approach to subscription management.

Architecture

The package follows a service-oriented architecture with clear separation of concerns:

  • Service: Main interface providing all subscription operations
  • Plan: Defines subscription tiers with limits and features
  • BillingProvider: Abstracts payment provider interactions
  • SubscriptionStore: Persists subscription data
  • ResourceCounterFunc: Tracks resource usage
  • PlansListSource: Loads plan definitions

Resource counting is delegated to the application layer through ResourceCounterFunc callbacks, allowing flexible implementation strategies (database aggregates, caching, external services). Plans are cached in memory after loading for optimal performance.

Core Components

The Service interface provides all subscription operations:

  • CanCreate: Check resource limits before creation
  • GetUsage: Get current usage and limits
  • HasFeature: Check feature availability
  • CreateCheckoutLink: Generate payment links
  • HandleWebhook: Process provider events

Plans define subscription tiers with:

  • Resource limits (users, projects, storage, etc.)
  • Feature flags (AI, SSO, API access, etc.)
  • Trial periods and pricing
  • Billing intervals (monthly, annual, none for free)

Quick Start

Create a subscription service with plans, provider, and storage:

import "github.com/dmitrymomot/saaskit/pkg/subscription"

// Define subscription plans
plans := []subscription.Plan{
	{
		ID:       "free",
		Name:     "Free Tier",
		Interval: subscription.BillingIntervalNone,
		Limits: map[subscription.Resource]int64{
			subscription.ResourceUsers:    1,
			subscription.ResourceProjects: 3,
		},
	},
	{
		ID:       "price_pro_monthly", // Must match provider's price ID
		Name:     "Professional",
		Interval: subscription.BillingIntervalMonthly,
		Price:    subscription.Money{Amount: 9900, Currency: "USD"},
		Limits: map[subscription.Resource]int64{
			subscription.ResourceUsers:    subscription.Unlimited,
			subscription.ResourceProjects: subscription.Unlimited,
		},
		Features: []subscription.Feature{
			subscription.FeatureAI,
			subscription.FeatureSSO,
		},
		TrialDays: 14,
	},
}

// Setup counter functions
userCounter := func(ctx context.Context, tenantID uuid.UUID) (int64, error) {
	// Count users for this tenant from your database
	return db.CountUsers(ctx, tenantID)
}

projectCounter := func(ctx context.Context, tenantID uuid.UUID) (int64, error) {
	// Count projects for this tenant
	return db.CountProjects(ctx, tenantID)
}

// Create service
svc, err := subscription.NewService(
	ctx,
	subscription.NewInMemSource(plans...),
	provider, // Your BillingProvider implementation
	store,    // Your SubscriptionStore implementation
	subscription.WithCounter(subscription.ResourceUsers, userCounter),
	subscription.WithCounter(subscription.ResourceProjects, projectCounter),
)

Paddle Integration

The package includes a complete Paddle implementation. Set up Paddle provider:

import "github.com/dmitrymomot/saaskit/pkg/subscription"

// Configure Paddle
paddleConfig := subscription.PaddleConfig{
	APIKey:        "your-paddle-api-key",
	WebhookSecret: "your-paddle-webhook-secret",
	Environment:   "sandbox", // or "production"
}

// Create Paddle provider
provider, err := subscription.NewPaddleProvider(paddleConfig)
if err != nil {
	log.Fatal("Failed to create Paddle provider:", err)
}

// Use in service creation
svc, err := subscription.NewService(ctx, planSource, provider, store)

Resource Management

Enforce resource limits before allowing resource creation:

// Before creating a user
err := svc.CanCreate(ctx, tenantID, subscription.ResourceUsers)
if errors.Is(err, subscription.ErrLimitExceeded) {
	// Show upgrade prompt or reject request
	return fmt.Errorf("user limit reached, please upgrade your plan")
}

// Create the user...
user := createUser(...)

// Get current usage and limits
used, limit, err := svc.GetUsage(ctx, tenantID, subscription.ResourceProjects)
if err != nil {
	// Handle error
}
fmt.Printf("Using %d of %d projects", used, limit)

// Get usage as percentage for UI progress bars
percentage := svc.GetUsagePercentage(ctx, tenantID, subscription.ResourceStorage)
// Returns 0-100 for normal limits, -1 for unlimited

Counter functions must be fast as they're called frequently. Consider:

  • Database indexes on tenant_id columns
  • Cached counts with periodic refresh
  • Eventual consistency for non-critical resources

Feature Control

Enable/disable features based on subscription plan:

// Check if AI features are available
if svc.HasFeature(ctx, tenantID, subscription.FeatureAI) {
	// Enable AI-powered features
	result := processWithAI(input)
} else {
	// Use basic processing
	result := processBasic(input)
}

// Feature checks are fail-closed for security
// Returns false on any error to prevent unauthorized access

Built-in features include:

  • FeatureAI: AI-powered capabilities
  • FeatureSSO: Single Sign-On integration
  • FeatureAPI: API access
  • FeatureWebhooks: Webhook functionality
  • FeatureAnalytics: Advanced analytics
  • And more...

Plan Management

Set plan context for dynamic plan resolution:

import "github.com/dmitrymomot/saaskit/pkg/subscription"

// Set plan ID in request context
ctx = subscription.SetPlanIDToContext(ctx, "pro_monthly")

// Service will use this plan for all operations
canCreate := svc.CanCreate(ctx, tenantID, subscription.ResourceUsers)

Alternative: Custom plan resolver from database:

dbResolver := func(ctx context.Context, tenantID uuid.UUID) (string, error) {
	return db.GetTenantPlanID(ctx, tenantID)
}

svc, err := subscription.NewService(
	ctx, planSource, provider, store,
	subscription.WithPlanIDResolver(dbResolver),
)

Checkout and Billing

Create checkout sessions for plan upgrades:

// Create checkout link
link, err := svc.CreateCheckoutLink(ctx, tenantID, "price_pro_monthly",
	subscription.CheckoutOptions{
		Email:      user.Email,        // Pre-fill billing email
		SuccessURL: "https://app.com/success",
		CancelURL:  "https://app.com/cancel",
	},
)
if err != nil {
	// Handle error
}

// Redirect user to hosted checkout
http.Redirect(w, r, link.URL, http.StatusSeeOther)

// Get customer portal link for plan management
portal, err := svc.GetCustomerPortalLink(ctx, tenantID)
if err != nil {
	// Handle error (free plans return error as they have no portal)
}
// portal.URL - general portal
// portal.CancelURL - direct to cancellation (if available)
// portal.UpdatePaymentURL - direct to payment update (if available)

Free plans bypass payment processing and activate immediately.

Webhook Processing

Process billing provider webhooks to sync subscription state:

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	// Process webhook directly with the request
	// The provider will extract signature from appropriate header
	// (e.g., Paddle-Signature, Stripe-Signature, etc.)
	err := svc.HandleWebhook(r)
	if err != nil {
		log.Printf("Webhook error: %v", err)
		http.Error(w, "Webhook processing failed", http.StatusBadRequest)
		return
	}

	w.WriteHeader(http.StatusOK)
}

Webhook events automatically update subscription status, plan changes, and trial states in your SubscriptionStore implementation.

Trial Management

Plans can include trial periods that are automatically managed:

// Check if trial is active
sub, err := svc.GetSubscription(ctx, tenantID)
if err != nil {
	// Handle error
}

if sub.IsTrialing() {
	daysLeft := sub.TrialDaysRemaining()
	fmt.Printf("Trial expires in %d days", daysLeft)

	// Show trial warning when close to expiration
	if daysLeft <= 3 {
		showTrialWarning()
	}
}

// Verify trial status before allowing access
err = svc.CheckTrial(ctx, tenantID, sub.CreatedAt)
if errors.Is(err, subscription.ErrTrialExpired) {
	// Block access and show upgrade prompt
	showUpgradePrompt()
	return
}

Error Handling

The package defines specific errors for different scenarios:

switch {
case errors.Is(err, subscription.ErrLimitExceeded):
	// Resource limit reached - show upgrade prompt
	showUpgradePrompt(resource)

case errors.Is(err, subscription.ErrTrialExpired):
	// Trial period ended - require plan selection
	redirectToPlans()

case errors.Is(err, subscription.ErrPlanNotFound):
	// Invalid plan ID - configuration error
	log.Error("Invalid plan configuration")

case errors.Is(err, subscription.ErrNoCounterRegistered):
	// Counter not registered for resource - configuration error
	log.Error("Resource counter not configured")

case errors.Is(err, subscription.ErrSubscriptionNotFound):
	// No subscription exists - redirect to plan selection
	redirectToPlans()
}

Performance Considerations

For optimal performance:

  • Resource counters are called frequently - optimize with indexes and caching
  • Plans are cached in memory - plan changes require service restart
  • Feature checks are fail-closed - returns false on errors for security
  • Database queries should use tenant_id indexes
  • Consider read replicas for counter queries
  • Cache frequently accessed usage data

Storage Implementation

Implement SubscriptionStore for your database:

type MySubscriptionStore struct {
	db *sql.DB
}

func (s *MySubscriptionStore) Get(ctx context.Context, tenantID uuid.UUID) (*subscription.Subscription, error) {
	// Query subscription by tenant_id
	// Return subscription.ErrSubscriptionNotFound if not found
}

func (s *MySubscriptionStore) Save(ctx context.Context, sub *subscription.Subscription) error {
	// Insert or update subscription using tenant_id as primary key
}

The store interface is minimal to support various database systems and ORMs.

Index

Constants

View Source
const (
	DefaultCheckoutExpiry = 24 * time.Hour // Default checkout link expiration
	DefaultPortalExpiry   = 24 * time.Hour // Default portal link expiration
	FreeCheckoutExpiry    = 5 * time.Minute
)

Common durations for all billing providers

View Source
const (
	// Unlimited indicates no limit for a resource (-1 chosen for SQL compatibility)
	Unlimited int64 = -1
)

Variables

View Source
var (
	ErrPlanNotFound             = errors.New("subscription plan not found")
	ErrPlanIDNotFound           = errors.New("subscription plan ID not found")
	ErrPlanIDNotInContext       = errors.New("subscription plan ID not found in context")
	ErrInvalidPlanConfiguration = errors.New("invalid subscription plan configuration")

	ErrLimitExceeded        = errors.New("subscription limit exceeded")
	ErrInvalidResource      = errors.New("invalid subscription resource")
	ErrNoCounterRegistered  = errors.New("no usage counter registered for resource")
	ErrDowngradeNotPossible = errors.New("subscription downgrade not possible")

	ErrTrialExpired      = errors.New("subscription trial has expired")
	ErrTrialNotAvailable = errors.New("subscription trial not available")

	ErrSubscriptionNotFound      = errors.New("subscription not found")
	ErrSubscriptionAlreadyExists = errors.New("subscription already exists")
	ErrInvalidSubscriptionState  = errors.New("invalid subscription state")
	ErrProviderError             = errors.New("subscription provider error")

	ErrFailedToLoadPlans          = errors.New("failed to load subscription plans")
	ErrFailedToCountResourceUsage = errors.New("failed to count resource usage")

	// Provider-specific errors
	ErrMissingAPIKey              = errors.New("billing provider API key is required")
	ErrMissingWebhookSecret       = errors.New("billing provider webhook secret is required")
	ErrInvalidProviderEnvironment = errors.New("invalid billing provider environment")
	ErrWebhookVerificationFailed  = errors.New("webhook signature verification failed")
	ErrNoCheckoutURL              = errors.New("no checkout URL returned from provider")
	ErrNoPortalURL                = errors.New("no portal URL returned from provider")
	ErrMissingProviderCustomerID  = errors.New("provider customer ID not available")
	ErrMissingProviderSubID       = errors.New("subscription provider ID is required for customer portal access")
	ErrMissingTenantID            = errors.New("tenant ID is required")
	ErrMissingPriceID             = errors.New("price ID is required")

	// Webhook processing errors
	ErrMissingTenantIDInWebhook = errors.New("missing tenant ID in webhook event")
	ErrWebhookVerification      = errors.New("webhook verification error")
	ErrFailedToReadRequestBody  = errors.New("failed to read request body")
	ErrFailedToParseWebhook     = errors.New("failed to parse webhook payload")

	// Provider operation errors
	ErrFailedToCreatePaddleClient  = errors.New("failed to create paddle client")
	ErrFailedToCreateTransaction   = errors.New("failed to create paddle transaction")
	ErrFailedToCreatePortalSession = errors.New("failed to create paddle customer portal session")
	ErrNoPortalForFreePlan         = errors.New("no customer portal available for free plans")

	// Subscription operation errors
	ErrFailedToSaveSubscription         = errors.New("failed to save subscription")
	ErrFailedToUpdateSubscription       = errors.New("failed to update subscription")
	ErrFailedToCancelSubscription       = errors.New("failed to cancel subscription")
	ErrFailedToUpdateSubscriptionStatus = errors.New("failed to update subscription status")

	// Configuration errors
	ErrPlanIDMismatch    = errors.New("plan ID mismatch in configuration")
	ErrNegativeTrialDays = errors.New("plan has negative trial days")
)

Functions

func GetPlanIDFromContext

func GetPlanIDFromContext(ctx context.Context) (string, bool)

func PlanIDContextResolver

func PlanIDContextResolver(ctx context.Context, _ uuid.UUID) (string, error)

PlanIDContextResolver is the default resolver that retrieves plan ID from context. This resolver allows dynamic plan resolution without database lookups, useful for multi-tenant applications where plan ID is determined during request processing.

func SetPlanIDToContext

func SetPlanIDToContext(ctx context.Context, planID string) context.Context

Types

type BillingInterval

type BillingInterval string

BillingInterval represents the billing frequency for a subscription plan.

const (
	BillingIntervalNone    BillingInterval = "none" // for free plans
	BillingIntervalMonthly BillingInterval = "monthly"
	BillingIntervalAnnual  BillingInterval = "annual"
)

type BillingProvider

type BillingProvider interface {
	// CreateCheckoutLink creates a hosted checkout session
	CreateCheckoutLink(ctx context.Context, req CheckoutRequest) (*CheckoutLink, error)

	// GetCustomerPortalLink returns a temporary link to the customer portal
	// where users can update payment methods, cancel, or change plans.
	// The provider implementation decides which fields to use (e.g., Paddle uses TenantID as customer ID)
	GetCustomerPortalLink(ctx context.Context, subscription *Subscription) (*PortalLink, error)

	// ParseWebhook validates and parses incoming webhook data from HTTP request.
	// Must validate signature headers to prevent webhook spoofing attacks.
	// Each provider looks for their specific signature headers (e.g., Paddle-Signature).
	// Returns normalized event type and raw provider data.
	ParseWebhook(r *http.Request) (*WebhookEvent, error)
}

BillingProvider defines the minimal interface for payment provider integrations. This abstraction allows support for different providers (Paddle, Stripe, Lemonsqueezy) while avoiding vendor lock-in. Provider handles all payment complexity through hosted checkouts and customer portals, eliminating PCI compliance concerns.

Implementations should use official provider SDKs and handle provider-specific quirks internally (e.g., Paddle's customer ID mapping, Stripe's metadata fields).

type CheckoutLink struct {
	URL       string    // hosted checkout URL
	SessionID string    // provider's session identifier
	ExpiresAt time.Time // link expiration
}

CheckoutLink represents a hosted checkout session.

type CheckoutOptions

type CheckoutOptions struct {
	Email      string // pre-fill billing email
	SuccessURL string // redirect after successful payment
	CancelURL  string // redirect if customer cancels
}

CheckoutOptions contains options for creating a checkout session.

type CheckoutRequest

type CheckoutRequest struct {
	PriceID    string    // provider's price/plan identifier
	TenantID   uuid.UUID // your internal tenant ID
	Email      string    // optional billing email
	SuccessURL string    // redirect after successful payment
	CancelURL  string    // redirect if customer cancels
}

CheckoutRequest contains data needed to create a checkout session.

type EventType

type EventType string

EventType represents the normalized billing event type. Each provider implementation maps their specific events to these types.

const (
	EventSubscriptionCreated   EventType = "subscription_created"
	EventSubscriptionUpdated   EventType = "subscription_updated"
	EventSubscriptionCancelled EventType = "subscription_cancelled"
	EventSubscriptionResumed   EventType = "subscription_resumed"

	EventPaymentSucceeded EventType = "payment_succeeded"
	EventPaymentFailed    EventType = "payment_failed"
)

type Feature

type Feature string

Feature represents a plan-specific capability that can be enabled/disabled.

const (
	FeatureAI                Feature = "ai"
	FeatureSSO               Feature = "sso"
	FeatureAPI               Feature = "api"
	FeatureWebhooks          Feature = "webhooks"
	FeatureWhiteLabel        Feature = "white_label"
	FeatureAnalytics         Feature = "analytics"
	FeaturePrioritySupport   Feature = "priority_support"
	FeatureCustomDomain      Feature = "custom_domain"
	FeatureTeamCollaboration Feature = "team_collaboration"
	FeatureExport            Feature = "export"
	FeatureIntegrations      Feature = "integrations"
	FeatureAuditLog          Feature = "audit_log"
)

type Money

type Money struct {
	Amount   int64  // in smallest currency unit (cents for USD)
	Currency string // ISO 4217 currency code
}

Money represents a monetary amount in the smallest currency unit. For example, $10.99 USD would be Amount: 1099, Currency: "USD".

type PaddleConfig

type PaddleConfig struct {
	APIKey        string `env:"PADDLE_API_KEY,required"`
	WebhookSecret string `env:"PADDLE_WEBHOOK_SECRET,required"`
	Environment   string `env:"PADDLE_ENVIRONMENT" envDefault:"production"`
}

PaddleConfig holds configuration for Paddle billing provider.

func (PaddleConfig) Validate

func (c PaddleConfig) Validate() error

Validate checks if the configuration is valid.

type PaddleProvider

type PaddleProvider struct {
	// contains filtered or unexported fields
}

PaddleProvider implements BillingProvider for Paddle.

func NewPaddleProvider

func NewPaddleProvider(config PaddleConfig) (*PaddleProvider, error)

NewPaddleProvider creates a new Paddle billing provider.

func (p *PaddleProvider) CreateCheckoutLink(ctx context.Context, req CheckoutRequest) (*CheckoutLink, error)

CreateCheckoutLink creates a hosted checkout session in Paddle.

func (p *PaddleProvider) GetCustomerPortalLink(ctx context.Context, subscription *Subscription) (*PortalLink, error)

GetCustomerPortalLink returns a link to Paddle's customer portal.

func (*PaddleProvider) ParseWebhook

func (p *PaddleProvider) ParseWebhook(req *http.Request) (*WebhookEvent, error)

ParseWebhook validates and parses incoming webhook data from HTTP request.

type Plan

type Plan struct {
	ID          string // provider's price ID (e.g., price_starter_monthly)
	Name        string
	Description string
	Limits      map[Resource]int64 // -1 represents unlimited
	Features    []Feature
	Public      bool // available for self-service signup
	TrialDays   int
	Price       Money
	Interval    BillingInterval
}

Plan describes a subscription plan and its resource/feature constraints. The ID field should be set to the payment provider's price ID for paid plans to enable direct mapping during checkout and webhook processing.

func (Plan) IsTrialActive

func (p Plan) IsTrialActive(startedAt time.Time) bool

IsTrialActive reports whether the tenant is still in its trial window.

func (Plan) TrialEndsAt

func (p Plan) TrialEndsAt(startedAt time.Time) time.Time

TrialEndsAt calculates when the trial period ends. Returns startedAt unchanged if no trial is available.

type PlanComparison

type PlanComparison struct {
	NewFeatures      []Feature
	LostFeatures     []Feature
	IncreasedLimits  map[Resource]ResourceChange
	DecreasedLimits  map[Resource]ResourceChange
	NewResources     map[Resource]int64
	RemovedResources map[Resource]int64
}

PlanComparison contains the differences between two plans. Used to validate downgrades and communicate changes to users.

func ComparePlans

func ComparePlans(current, target *Plan) *PlanComparison

ComparePlans returns the differences between current and target plans.

func (*PlanComparison) HasResourceDecreases

func (c *PlanComparison) HasResourceDecreases() bool

HasResourceDecreases returns true if any resources have decreased limits.

type PlanIDResolver

type PlanIDResolver func(ctx context.Context, tenantID uuid.UUID) (string, error)

PlanIDResolver resolves a plan ID for a given tenant.

type PlansListSource

type PlansListSource interface {
	Load(ctx context.Context) (map[string]Plan, error)
}

PlansListSource defines how plans are loaded into the subscription service.

func NewInMemSource

func NewInMemSource(plans ...Plan) PlansListSource

NewInMemSource returns an in-memory Source with a deep copy of the given plans. Panics if no plans are provided to ensure the service always has at least one valid plan. Deep copying prevents external modifications from affecting the source's state.

type PortalLink struct {
	URL              string    // general portal URL (always populated)
	CancelURL        string    // optional: direct to cancellation flow
	UpdatePaymentURL string    // optional: direct to payment method update
	ExpiresAt        time.Time // link expiration (usually 24 hours)
}

PortalLink represents a customer portal session with optional action-specific URLs.

type Resource

type Resource string

Resource represents a countable tenant resource type.

const (
	ResourceUsers        Resource = "users"
	ResourceProjects     Resource = "projects"
	ResourceAPIKeys      Resource = "api_keys"
	ResourceWebhooks     Resource = "webhooks"
	ResourceEmails       Resource = "emails"
	ResourceTickets      Resource = "tickets"
	ResourceTeamMembers  Resource = "team_members"
	ResourceEnvironments Resource = "environments"
	ResourceReferrals    Resource = "referrals"
	ResourceCampaigns    Resource = "campaigns"
	ResourceStorage      Resource = "storage"   // in GB
	ResourceBandwidth    Resource = "bandwidth" // in GB
	ResourceDomains      Resource = "domains"
)

type ResourceChange

type ResourceChange struct {
	From int64
	To   int64
}

ResourceChange represents a change in resource limit.

type ResourceCounterFunc

type ResourceCounterFunc func(ctx context.Context, tenantID uuid.UUID) (int64, error)

ResourceCounterFunc returns the current usage for a tenant resource. Must be fast and ideally cached as it's called on every resource creation attempt. Consider implementing counters with database aggregates or cached values.

type Service

type Service interface {
	// Resource limits and feature checks
	CanCreate(ctx context.Context, tenantID uuid.UUID, res Resource) error
	GetUsage(ctx context.Context, tenantID uuid.UUID, res Resource) (used, limit int64, err error)
	GetUsageSafe(ctx context.Context, tenantID uuid.UUID, res Resource) (used, limit int64)
	HasFeature(ctx context.Context, tenantID uuid.UUID, feature Feature) bool
	CheckTrial(ctx context.Context, tenantID uuid.UUID, startedAt time.Time) error
	VerifyPlan(ctx context.Context, planID string) error
	GetUsagePercentage(ctx context.Context, tenantID uuid.UUID, res Resource) int
	CanDowngrade(ctx context.Context, tenantID uuid.UUID, targetPlanID string) error
	GetAllUsage(ctx context.Context, tenantID uuid.UUID) (map[Resource]UsageInfo, error)

	// Subscription management
	GetSubscription(ctx context.Context, tenantID uuid.UUID) (*Subscription, error)

	// Billing provider interactions
	CreateCheckoutLink(ctx context.Context, tenantID uuid.UUID, planID string, opts CheckoutOptions) (*CheckoutLink, error)
	GetCustomerPortalLink(ctx context.Context, tenantID uuid.UUID) (*PortalLink, error)
	HandleWebhook(r *http.Request) error
}

Service defines the public interface for subscription management.

func NewService

func NewService(ctx context.Context, src PlansListSource, provider BillingProvider, store SubscriptionStore, opts ...ServiceOption) (Service, error)

NewService creates a new Service with the given dependencies. Panics if required parameters (src, provider, store) are nil to fail fast during initialization. This prevents runtime errors from misconfigured services. Use ServiceOption functions to configure optional settings like custom plan ID resolver.

type ServiceOption

type ServiceOption func(*service)

ServiceOption configures a Service instance.

func WithCounter

func WithCounter(resource Resource, fn ResourceCounterFunc) ServiceOption

WithCounter registers a counter function for a specific resource. Counter functions must be fast as they're called on every creation attempt. Panics if a counter for the same resource has already been registered to prevent accidental overwrites and ensure explicit configuration.

func WithPlanIDResolver

func WithPlanIDResolver(resolver PlanIDResolver) ServiceOption

WithPlanIDResolver sets a custom plan ID resolver. Default resolver (PlanIDContextResolver) expects plan ID in context. Use this to implement database-backed plan resolution or other strategies.

type Subscription

type Subscription struct {
	TenantID           uuid.UUID // primary key - one subscription per tenant
	PlanID             string
	Status             SubscriptionStatus
	ProviderSubID      string // provider's subscription ID (empty for free plans)
	ProviderCustomerID string // provider's customer ID (ctm_xxx, cus_xxx, etc)
	CreatedAt          time.Time
	TrialEndsAt        *time.Time // set only for plans with trials
	UpdatedAt          time.Time
	CancelledAt        *time.Time // set when subscription is cancelled
}

Subscription represents a tenant's subscription to a plan. Each tenant has exactly one active subscription at a time.

func (*Subscription) IsActive

func (s *Subscription) IsActive() bool

func (*Subscription) IsCancelled

func (s *Subscription) IsCancelled() bool

func (*Subscription) IsTrialExpired

func (s *Subscription) IsTrialExpired() bool

func (*Subscription) IsTrialing

func (s *Subscription) IsTrialing() bool

func (*Subscription) TrialDaysRemaining

func (s *Subscription) TrialDaysRemaining() int

TrialDaysRemaining returns the number of days remaining in the trial. Returns 0 if not in trial or trial has expired.

func (*Subscription) TrialDaysRemainingAt

func (s *Subscription) TrialDaysRemainingAt(now time.Time) int

TrialDaysRemainingAt returns the number of days remaining in the trial at a given time. Returns 0 if not in trial or trial has expired. This method is useful for testing with fixed time values.

type SubscriptionStatus

type SubscriptionStatus string

SubscriptionStatus represents the current state of a subscription.

const (
	StatusTrialing  SubscriptionStatus = "trialing"
	StatusActive    SubscriptionStatus = "active"
	StatusPastDue   SubscriptionStatus = "past_due"
	StatusCancelled SubscriptionStatus = "cancelled"
	StatusExpired   SubscriptionStatus = "expired"
)

type SubscriptionStore

type SubscriptionStore interface {
	// Get retrieves a subscription by tenant ID.
	// Returns ErrSubscriptionNotFound if no subscription exists.
	Get(ctx context.Context, tenantID uuid.UUID) (*Subscription, error)

	// Save creates or updates a subscription.
	// Implementation should use TenantID to determine if it's an update.
	Save(ctx context.Context, subscription *Subscription) error
}

SubscriptionStore defines the interface for subscription persistence. Each tenant has exactly one subscription, so TenantID serves as the primary key.

type UsageInfo

type UsageInfo struct {
	Current int64
	Limit   int64
}

UsageInfo contains the current usage and limit for a resource.

type WebhookEvent

type WebhookEvent struct {
	Type           EventType      // normalized event type
	ProviderEvent  string         // original provider event name
	SubscriptionID string         // provider's subscription ID
	TenantID       uuid.UUID      // your internal tenant ID (from custom_data)
	CustomerID     string         // provider's customer ID (ctm_xxx, cus_xxx, etc)
	Status         string         // subscription status
	PlanID         string         // the plan/price they subscribed to
	Raw            map[string]any // full webhook data
}

WebhookEvent represents a normalized webhook event from the billing provider.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL