turash/bugulma/backend/internal/service/subscription_service.go

284 lines
8.5 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"time"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/repository"
"github.com/google/uuid"
)
// SubscriptionFeature represents a subscription feature flag
type SubscriptionFeature string
const (
FeatureUnlimitedOrganizations SubscriptionFeature = "unlimited_organizations"
FeatureAdvancedAnalytics SubscriptionFeature = "advanced_analytics"
FeatureAPIAccess SubscriptionFeature = "api_access"
FeatureCustomDomain SubscriptionFeature = "custom_domain"
FeatureSSO SubscriptionFeature = "sso"
FeaturePrioritySupport SubscriptionFeature = "priority_support"
FeatureDedicatedSupport SubscriptionFeature = "dedicated_support"
FeatureTeamCollaboration SubscriptionFeature = "team_collaboration"
FeatureWhiteLabel SubscriptionFeature = "white_label"
)
// PlanFeatures maps plans to their features
var PlanFeatures = map[domain.SubscriptionPlan][]SubscriptionFeature{
domain.SubscriptionPlanFree: {},
domain.SubscriptionPlanBasic: {FeatureTeamCollaboration},
domain.SubscriptionPlanProfessional: {
FeatureUnlimitedOrganizations,
FeatureAdvancedAnalytics,
FeatureAPIAccess,
FeatureTeamCollaboration,
FeaturePrioritySupport,
},
domain.SubscriptionPlanEnterprise: {
FeatureUnlimitedOrganizations,
FeatureAdvancedAnalytics,
FeatureAPIAccess,
FeatureCustomDomain,
FeatureSSO,
FeatureTeamCollaboration,
FeatureDedicatedSupport,
FeatureWhiteLabel,
},
}
// PlanLimits maps plans to their limits (-1 means unlimited)
var PlanLimits = map[domain.SubscriptionPlan]map[domain.UsageLimitType]int64{
domain.SubscriptionPlanFree: {
domain.UsageLimitTypeOrganizations: 3,
domain.UsageLimitTypeUsers: 1,
domain.UsageLimitTypeStorage: 100, // MB
domain.UsageLimitTypeAPICalls: 1000,
},
domain.SubscriptionPlanBasic: {
domain.UsageLimitTypeOrganizations: 10,
domain.UsageLimitTypeUsers: 5,
domain.UsageLimitTypeStorage: 1000, // MB
domain.UsageLimitTypeAPICalls: 10000,
},
domain.SubscriptionPlanProfessional: {
domain.UsageLimitTypeOrganizations: -1, // unlimited
domain.UsageLimitTypeUsers: 20,
domain.UsageLimitTypeStorage: 10000, // MB
domain.UsageLimitTypeAPICalls: 100000,
},
domain.SubscriptionPlanEnterprise: {
domain.UsageLimitTypeOrganizations: -1, // unlimited
domain.UsageLimitTypeUsers: -1, // unlimited
domain.UsageLimitTypeStorage: -1, // unlimited
domain.UsageLimitTypeAPICalls: -1, // unlimited
domain.UsageLimitTypeCustomDomains: 5,
},
}
type SubscriptionService struct {
subscriptionRepo domain.SubscriptionRepository
usageRepo domain.UsageTrackingRepository
userRepo domain.UserRepository
}
func NewSubscriptionService(
subscriptionRepo domain.SubscriptionRepository,
usageRepo domain.UsageTrackingRepository,
userRepo domain.UserRepository,
) *SubscriptionService {
return &SubscriptionService{
subscriptionRepo: subscriptionRepo,
usageRepo: usageRepo,
userRepo: userRepo,
}
}
// GetSubscription retrieves a user's subscription, creating a free one if none exists
func (s *SubscriptionService) GetSubscription(ctx context.Context, userID string) (*domain.Subscription, error) {
subscription, err := s.subscriptionRepo.GetByUserID(ctx, userID)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// Create default free subscription
return s.CreateSubscription(ctx, userID, domain.SubscriptionPlanFree, domain.BillingPeriodMonthly)
}
return nil, err
}
return subscription, nil
}
// CreateSubscription creates a new subscription
func (s *SubscriptionService) CreateSubscription(ctx context.Context, userID string, plan domain.SubscriptionPlan, billingPeriod domain.BillingPeriod) (*domain.Subscription, error) {
// Verify user exists
_, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
now := time.Now()
var periodEnd time.Time
if billingPeriod == domain.BillingPeriodYearly {
periodEnd = now.AddDate(1, 0, 0)
} else {
periodEnd = now.AddDate(0, 1, 0)
}
subscription := &domain.Subscription{
ID: uuid.New().String(),
UserID: userID,
Plan: plan,
Status: domain.SubscriptionStatusActive,
BillingPeriod: billingPeriod,
CurrentPeriodStart: now,
CurrentPeriodEnd: periodEnd,
CancelAtPeriodEnd: false,
}
if plan == domain.SubscriptionPlanFree {
subscription.Status = domain.SubscriptionStatusNone
}
if err := s.subscriptionRepo.Create(ctx, subscription); err != nil {
return nil, fmt.Errorf("failed to create subscription: %w", err)
}
return subscription, nil
}
// UpdateSubscription updates a subscription (upgrade/downgrade)
func (s *SubscriptionService) UpdateSubscription(ctx context.Context, subscriptionID string, newPlan domain.SubscriptionPlan) (*domain.Subscription, error) {
subscription, err := s.subscriptionRepo.GetByID(ctx, subscriptionID)
if err != nil {
return nil, err
}
subscription.Plan = newPlan
if newPlan == domain.SubscriptionPlanFree {
subscription.Status = domain.SubscriptionStatusNone
} else if subscription.Status == domain.SubscriptionStatusNone {
subscription.Status = domain.SubscriptionStatusActive
}
if err := s.subscriptionRepo.Update(ctx, subscription); err != nil {
return nil, fmt.Errorf("failed to update subscription: %w", err)
}
return subscription, nil
}
// CancelSubscription cancels a subscription
func (s *SubscriptionService) CancelSubscription(ctx context.Context, subscriptionID string) error {
subscription, err := s.subscriptionRepo.GetByID(ctx, subscriptionID)
if err != nil {
return fmt.Errorf("subscription not found: %w", err)
}
subscription.CancelAtPeriodEnd = true
if err := s.subscriptionRepo.Update(ctx, subscription); err != nil {
return fmt.Errorf("failed to cancel subscription: %w", err)
}
return nil
}
// CheckFeatureAccess checks if a user has access to a specific feature
func (s *SubscriptionService) CheckFeatureAccess(ctx context.Context, userID string, feature SubscriptionFeature) (bool, error) {
subscription, err := s.GetSubscription(ctx, userID)
if err != nil {
return false, err
}
// Check if subscription is active
if !s.isSubscriptionActive(subscription.Status) {
return false, nil
}
// Check if plan has feature
features, ok := PlanFeatures[subscription.Plan]
if !ok {
return false, nil
}
for _, f := range features {
if f == feature {
return true, nil
}
}
return false, nil
}
// CheckLimits checks if a user is within their subscription limits
func (s *SubscriptionService) CheckLimits(ctx context.Context, userID string, limitType domain.UsageLimitType, current int64) (bool, int64, error) {
subscription, err := s.GetSubscription(ctx, userID)
if err != nil {
return false, 0, err
}
// Get plan limits
planLimits, ok := PlanLimits[subscription.Plan]
if !ok {
return false, 0, fmt.Errorf("unknown plan: %s", subscription.Plan)
}
limit, ok := planLimits[limitType]
if !ok {
// No limit for this type
return true, -1, nil
}
if limit == -1 {
// Unlimited
return true, -1, nil
}
return current < limit, limit - current, nil
}
// GetUsageStats retrieves usage statistics for a user
func (s *SubscriptionService) GetUsageStats(ctx context.Context, userID string) (map[domain.UsageLimitType]int64, error) {
subscription, err := s.GetSubscription(ctx, userID)
if err != nil {
return nil, err
}
stats := make(map[domain.UsageLimitType]int64)
planLimits, ok := PlanLimits[subscription.Plan]
if !ok {
return stats, nil
}
// Get usage for each limit type
for limitType := range planLimits {
usage, err := s.usageRepo.GetCurrentPeriodUsage(ctx, userID, limitType)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
stats[limitType] = 0
continue
}
return nil, err
}
stats[limitType] = usage.CurrentUsage
}
return stats, nil
}
// isSubscriptionActive checks if a subscription status is active
func (s *SubscriptionService) isSubscriptionActive(status domain.SubscriptionStatus) bool {
return status == domain.SubscriptionStatusActive || status == domain.SubscriptionStatusTrialing
}
// GetPlanFeatures returns features for a plan
func (s *SubscriptionService) GetPlanFeatures(plan domain.SubscriptionPlan) []SubscriptionFeature {
return PlanFeatures[plan]
}
// GetPlanLimits returns limits for a plan
func (s *SubscriptionService) GetPlanLimits(plan domain.SubscriptionPlan) map[domain.UsageLimitType]int64 {
return PlanLimits[plan]
}