package services import ( "context" "errors" "time" "github.com/rs/zerolog/log" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/apperrors" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/repositories" ) // Subscription-related errors var ( // Deprecated: Use apperrors.NotFound("error.subscription_not_found") instead ErrSubscriptionNotFound = errors.New("subscription not found") // Deprecated: Use apperrors.Forbidden("error.properties_limit_exceeded") instead ErrPropertiesLimitExceeded = errors.New("properties limit exceeded for your subscription tier") // Deprecated: Use apperrors.Forbidden("error.tasks_limit_exceeded") instead ErrTasksLimitExceeded = errors.New("tasks limit exceeded for your subscription tier") // Deprecated: Use apperrors.Forbidden("error.contractors_limit_exceeded") instead ErrContractorsLimitExceeded = errors.New("contractors limit exceeded for your subscription tier") // Deprecated: Use apperrors.Forbidden("error.documents_limit_exceeded") instead ErrDocumentsLimitExceeded = errors.New("documents limit exceeded for your subscription tier") // Deprecated: Use apperrors.NotFound("error.upgrade_trigger_not_found") instead ErrUpgradeTriggerNotFound = errors.New("upgrade trigger not found") // Deprecated: Use apperrors.NotFound("error.promotion_not_found") instead ErrPromotionNotFound = errors.New("promotion not found") ) // KnownSubscriptionIDs are the product IDs for Pro subscriptions // Update these to match your actual App Store Connect / Google Play Console product IDs var KnownSubscriptionIDs = []string{ "com.tt.casera.pro.monthly", "com.tt.casera.pro.yearly", "casera_pro_monthly", "casera_pro_yearly", } // SubscriptionService handles subscription business logic type SubscriptionService struct { subscriptionRepo *repositories.SubscriptionRepository residenceRepo *repositories.ResidenceRepository taskRepo *repositories.TaskRepository contractorRepo *repositories.ContractorRepository documentRepo *repositories.DocumentRepository appleClient *AppleIAPClient googleClient *GoogleIAPClient } // NewSubscriptionService creates a new subscription service func NewSubscriptionService( subscriptionRepo *repositories.SubscriptionRepository, residenceRepo *repositories.ResidenceRepository, taskRepo *repositories.TaskRepository, contractorRepo *repositories.ContractorRepository, documentRepo *repositories.DocumentRepository, ) *SubscriptionService { svc := &SubscriptionService{ subscriptionRepo: subscriptionRepo, residenceRepo: residenceRepo, taskRepo: taskRepo, contractorRepo: contractorRepo, documentRepo: documentRepo, } // Initialize Apple IAP client cfg := config.Get() if cfg != nil { appleClient, err := NewAppleIAPClient(cfg.AppleIAP) if err != nil { if !errors.Is(err, ErrIAPNotConfigured) { log.Warn().Err(err).Msg("Failed to initialize Apple IAP client") } } else { svc.appleClient = appleClient log.Info().Msg("Apple IAP validation client initialized") } // Initialize Google IAP client ctx := context.Background() googleClient, err := NewGoogleIAPClient(ctx, cfg.GoogleIAP) if err != nil { if !errors.Is(err, ErrIAPNotConfigured) { log.Warn().Err(err).Msg("Failed to initialize Google IAP client") } } else { svc.googleClient = googleClient log.Info().Msg("Google IAP validation client initialized") } } return svc } // GetSubscription gets the subscription for a user func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionResponse, error) { sub, err := s.subscriptionRepo.GetOrCreate(userID) if err != nil { return nil, apperrors.Internal(err) } return NewSubscriptionResponse(sub), nil } // GetSubscriptionStatus gets detailed subscription status including limits func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionStatusResponse, error) { sub, err := s.subscriptionRepo.GetOrCreate(userID) if err != nil { return nil, apperrors.Internal(err) } settings, err := s.subscriptionRepo.GetSettings() if err != nil { return nil, apperrors.Internal(err) } // Auto-start trial for new users who have never had a trial if !sub.TrialUsed && sub.TrialEnd == nil && settings.TrialEnabled { now := time.Now().UTC() trialEnd := now.Add(time.Duration(settings.TrialDurationDays) * 24 * time.Hour) if err := s.subscriptionRepo.SetTrialDates(userID, now, trialEnd); err != nil { return nil, apperrors.Internal(err) } // Re-fetch after starting trial so response reflects the new state sub, err = s.subscriptionRepo.FindByUserID(userID) if err != nil { return nil, apperrors.Internal(err) } } // Get all tier limits and build a map allLimits, err := s.subscriptionRepo.GetAllTierLimits() if err != nil { return nil, apperrors.Internal(err) } limitsMap := make(map[string]*TierLimitsClientResponse) for _, l := range allLimits { limitsMap[string(l.Tier)] = NewTierLimitsClientResponse(&l) } // Ensure both free and pro exist with defaults if missing if _, ok := limitsMap["free"]; !ok { defaults := models.GetDefaultFreeLimits() limitsMap["free"] = NewTierLimitsClientResponse(&defaults) } if _, ok := limitsMap["pro"]; !ok { defaults := models.GetDefaultProLimits() limitsMap["pro"] = NewTierLimitsClientResponse(&defaults) } // Get current usage usage, err := s.getUserUsage(userID) if err != nil { return nil, err } // Determine if limitations are enabled for this user // If user has IsFree flag, always return false (no limitations) limitationsEnabled := settings.EnableLimitations if sub.IsFree { limitationsEnabled = false } // Build flattened response (KMM expects subscription fields at top level) resp := &SubscriptionStatusResponse{ Tier: string(sub.Tier), IsActive: sub.IsActive(), AutoRenew: sub.AutoRenew, Limits: limitsMap, Usage: usage, LimitationsEnabled: limitationsEnabled, } // Format dates if present if sub.SubscribedAt != nil { t := sub.SubscribedAt.Format("2006-01-02T15:04:05Z") resp.SubscribedAt = &t } if sub.ExpiresAt != nil { t := sub.ExpiresAt.Format("2006-01-02T15:04:05Z") resp.ExpiresAt = &t } // Populate trial fields if sub.TrialStart != nil { t := sub.TrialStart.Format("2006-01-02T15:04:05Z") resp.TrialStart = &t } if sub.TrialEnd != nil { t := sub.TrialEnd.Format("2006-01-02T15:04:05Z") resp.TrialEnd = &t } resp.TrialActive = sub.IsTrialActive() resp.SubscriptionSource = sub.SubscriptionSource() return resp, nil } // getUserUsage calculates current usage for a user. // Uses batch COUNT queries (O(1) queries) instead of per-residence queries (O(N)). func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) { residences, err := s.residenceRepo.FindOwnedByUser(userID) if err != nil { return nil, apperrors.Internal(err) } propertiesCount := int64(len(residences)) // Collect residence IDs for batch queries residenceIDs := make([]uint, len(residences)) for i, r := range residences { residenceIDs[i] = r.ID } // Count tasks, contractors, and documents across all residences with single queries each tasksCount, err := s.taskRepo.CountByResidenceIDs(residenceIDs) if err != nil { return nil, apperrors.Internal(err) } contractorsCount, err := s.contractorRepo.CountByResidenceIDs(residenceIDs) if err != nil { return nil, apperrors.Internal(err) } documentsCount, err := s.documentRepo.CountByResidenceIDs(residenceIDs) if err != nil { return nil, apperrors.Internal(err) } return &UsageResponse{ PropertiesCount: propertiesCount, TasksCount: tasksCount, ContractorsCount: contractorsCount, DocumentsCount: documentsCount, }, nil } // CheckLimit checks if a user has exceeded a specific limit func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error { settings, err := s.subscriptionRepo.GetSettings() if err != nil { return apperrors.Internal(err) } // If limitations are disabled globally, allow everything if !settings.EnableLimitations { return nil } sub, err := s.subscriptionRepo.GetOrCreate(userID) if err != nil { return apperrors.Internal(err) } // IsFree users bypass all limitations if sub.IsFree { return nil } // Pro users have unlimited access if sub.IsPro() { return nil } limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier) if err != nil { return apperrors.Internal(err) } usage, err := s.getUserUsage(userID) if err != nil { return err } switch limitType { case "properties": if limits.PropertiesLimit != nil && usage.PropertiesCount >= int64(*limits.PropertiesLimit) { return apperrors.Forbidden("error.properties_limit_exceeded") } case "tasks": if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) { return apperrors.Forbidden("error.tasks_limit_exceeded") } case "contractors": if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) { return apperrors.Forbidden("error.contractors_limit_exceeded") } case "documents": if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) { return apperrors.Forbidden("error.documents_limit_exceeded") } } return nil } // GetUpgradeTrigger gets an upgrade trigger by key func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResponse, error) { trigger, err := s.subscriptionRepo.GetUpgradeTrigger(key) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, apperrors.NotFound("error.upgrade_trigger_not_found") } return nil, apperrors.Internal(err) } return NewUpgradeTriggerResponse(trigger), nil } // GetAllUpgradeTriggers gets all active upgrade triggers as a map keyed by trigger_key // KMM client expects Map func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) { triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers() if err != nil { return nil, apperrors.Internal(err) } result := make(map[string]*UpgradeTriggerDataResponse) for _, t := range triggers { result[t.TriggerKey] = NewUpgradeTriggerDataResponse(&t) } return result, nil } // GetFeatureBenefits gets all feature benefits func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) { benefits, err := s.subscriptionRepo.GetFeatureBenefits() if err != nil { return nil, apperrors.Internal(err) } result := make([]FeatureBenefitResponse, len(benefits)) for i, b := range benefits { result[i] = *NewFeatureBenefitResponse(&b) } return result, nil } // GetActivePromotions gets active promotions for a user func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionResponse, error) { sub, err := s.subscriptionRepo.GetOrCreate(userID) if err != nil { return nil, apperrors.Internal(err) } promotions, err := s.subscriptionRepo.GetActivePromotions(sub.Tier) if err != nil { return nil, apperrors.Internal(err) } result := make([]PromotionResponse, len(promotions)) for i, p := range promotions { result[i] = *NewPromotionResponse(&p) } return result, nil } // ProcessApplePurchase processes an Apple IAP purchase // Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID) func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) { // Store receipt/transaction data dataToStore := receiptData if dataToStore == "" { dataToStore = transactionID } if err := s.subscriptionRepo.UpdateReceiptData(userID, dataToStore); err != nil { return nil, apperrors.Internal(err) } // Apple IAP client must be configured to validate purchases. // Without server-side validation, we cannot trust client-provided receipts. if s.appleClient == nil { log.Error().Uint("user_id", userID).Msg("Apple IAP validation not configured, rejecting purchase") return nil, apperrors.BadRequest("error.iap_validation_not_configured") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var result *AppleValidationResult var err error // Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1) if transactionID != "" { result, err = s.appleClient.ValidateTransaction(ctx, transactionID) } else if receiptData != "" { result, err = s.appleClient.ValidateReceipt(ctx, receiptData) } if err != nil { // Validation failed -- do NOT fall through to grant Pro. log.Error().Err(err).Uint("user_id", userID).Msg("Apple validation failed") return nil, err } if result == nil { return nil, apperrors.BadRequest("error.no_receipt_or_transaction") } expiresAt := result.ExpiresAt log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Str("env", result.Environment).Msg("Apple purchase validated") // Upgrade to Pro with the validated expiration if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil { return nil, apperrors.Internal(err) } return s.GetSubscription(userID) } // ProcessGooglePurchase processes a Google Play purchase // productID is optional but helps validate the specific subscription func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) { // Store purchase token first if err := s.subscriptionRepo.UpdatePurchaseToken(userID, purchaseToken); err != nil { return nil, apperrors.Internal(err) } // Google IAP client must be configured to validate purchases. // Without server-side validation, we cannot trust client-provided tokens. if s.googleClient == nil { log.Error().Uint("user_id", userID).Msg("Google IAP validation not configured, rejecting purchase") return nil, apperrors.BadRequest("error.iap_validation_not_configured") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var result *GoogleValidationResult var err error // If productID is provided, use it directly; otherwise try known IDs if productID != "" { result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken) } else { result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs) } if err != nil { // Validation failed -- do NOT fall through to grant Pro. log.Error().Err(err).Uint("user_id", userID).Msg("Google purchase validation failed") return nil, err } if result == nil { return nil, apperrors.BadRequest("error.no_purchase_token") } expiresAt := result.ExpiresAt log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Bool("auto_renew", result.AutoRenewing).Msg("Google purchase validated") // Acknowledge the subscription if not already acknowledged if !result.AcknowledgedState { if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil { log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to acknowledge Google subscription") // Don't fail the purchase, just log the warning } } // Upgrade to Pro with the validated expiration if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil { return nil, apperrors.Internal(err) } return s.GetSubscription(userID) } // CancelSubscription cancels a subscription (downgrades to free at end of period) func (s *SubscriptionService) CancelSubscription(userID uint) (*SubscriptionResponse, error) { if err := s.subscriptionRepo.SetAutoRenew(userID, false); err != nil { return nil, apperrors.Internal(err) } return s.GetSubscription(userID) } // IsAlreadyProFromOtherPlatform checks if a user already has an active Pro subscription // from a different platform than the one being requested. Returns (conflict, existingPlatform, error). func (s *SubscriptionService) IsAlreadyProFromOtherPlatform(userID uint, requestedPlatform string) (bool, string, error) { sub, err := s.subscriptionRepo.GetOrCreate(userID) if err != nil { return false, "", apperrors.Internal(err) } if !sub.IsPro() { return false, "", nil } if sub.Platform == requestedPlatform { return false, "", nil } return true, sub.Platform, nil } // === Response Types === // SubscriptionResponse represents a subscription in API response type SubscriptionResponse struct { Tier string `json:"tier"` SubscribedAt *string `json:"subscribed_at"` ExpiresAt *string `json:"expires_at"` AutoRenew bool `json:"auto_renew"` CancelledAt *string `json:"cancelled_at"` Platform string `json:"platform"` IsActive bool `json:"is_active"` IsPro bool `json:"is_pro"` TrialActive bool `json:"trial_active"` SubscriptionSource string `json:"subscription_source"` } // NewSubscriptionResponse creates a SubscriptionResponse from a model func NewSubscriptionResponse(s *models.UserSubscription) *SubscriptionResponse { resp := &SubscriptionResponse{ Tier: string(s.Tier), AutoRenew: s.AutoRenew, Platform: s.Platform, IsActive: s.IsActive(), IsPro: s.IsPro(), TrialActive: s.IsTrialActive(), SubscriptionSource: s.SubscriptionSource(), } if s.SubscribedAt != nil { t := s.SubscribedAt.Format("2006-01-02T15:04:05Z") resp.SubscribedAt = &t } if s.ExpiresAt != nil { t := s.ExpiresAt.Format("2006-01-02T15:04:05Z") resp.ExpiresAt = &t } if s.CancelledAt != nil { t := s.CancelledAt.Format("2006-01-02T15:04:05Z") resp.CancelledAt = &t } return resp } // TierLimitsResponse represents tier limits type TierLimitsResponse struct { Tier string `json:"tier"` PropertiesLimit *int `json:"properties_limit"` TasksLimit *int `json:"tasks_limit"` ContractorsLimit *int `json:"contractors_limit"` DocumentsLimit *int `json:"documents_limit"` } // NewTierLimitsResponse creates a TierLimitsResponse from a model func NewTierLimitsResponse(l *models.TierLimits) *TierLimitsResponse { return &TierLimitsResponse{ Tier: string(l.Tier), PropertiesLimit: l.PropertiesLimit, TasksLimit: l.TasksLimit, ContractorsLimit: l.ContractorsLimit, DocumentsLimit: l.DocumentsLimit, } } // UsageResponse represents current usage (KMM client expects _count suffix) type UsageResponse struct { PropertiesCount int64 `json:"properties_count"` TasksCount int64 `json:"tasks_count"` ContractorsCount int64 `json:"contractors_count"` DocumentsCount int64 `json:"documents_count"` } // TierLimitsClientResponse represents tier limits for mobile client (simple field names) type TierLimitsClientResponse struct { Properties *int `json:"properties"` Tasks *int `json:"tasks"` Contractors *int `json:"contractors"` Documents *int `json:"documents"` } // NewTierLimitsClientResponse creates a TierLimitsClientResponse from a model func NewTierLimitsClientResponse(l *models.TierLimits) *TierLimitsClientResponse { return &TierLimitsClientResponse{ Properties: l.PropertiesLimit, Tasks: l.TasksLimit, Contractors: l.ContractorsLimit, Documents: l.DocumentsLimit, } } // SubscriptionStatusResponse represents full subscription status // Fields are flattened to match KMM client expectations type SubscriptionStatusResponse struct { // Tier and active status Tier string `json:"tier"` IsActive bool `json:"is_active"` // Flattened subscription fields (KMM expects these at top level) SubscribedAt *string `json:"subscribed_at"` ExpiresAt *string `json:"expires_at"` AutoRenew bool `json:"auto_renew"` // Trial fields TrialStart *string `json:"trial_start,omitempty"` TrialEnd *string `json:"trial_end,omitempty"` TrialActive bool `json:"trial_active"` // Subscription source SubscriptionSource string `json:"subscription_source"` // Other fields Usage *UsageResponse `json:"usage"` Limits map[string]*TierLimitsClientResponse `json:"limits"` LimitationsEnabled bool `json:"limitations_enabled"` } // UpgradeTriggerResponse represents an upgrade trigger (includes trigger_key) type UpgradeTriggerResponse struct { TriggerKey string `json:"trigger_key"` Title string `json:"title"` Message string `json:"message"` PromoHTML string `json:"promo_html"` ButtonText string `json:"button_text"` } // NewUpgradeTriggerResponse creates an UpgradeTriggerResponse from a model func NewUpgradeTriggerResponse(t *models.UpgradeTrigger) *UpgradeTriggerResponse { return &UpgradeTriggerResponse{ TriggerKey: t.TriggerKey, Title: t.Title, Message: t.Message, PromoHTML: t.PromoHTML, ButtonText: t.ButtonText, } } // UpgradeTriggerDataResponse represents trigger data for map values (no trigger_key) // Matches KMM UpgradeTriggerData model type UpgradeTriggerDataResponse struct { Title string `json:"title"` Message string `json:"message"` PromoHTML *string `json:"promo_html"` ButtonText string `json:"button_text"` } // NewUpgradeTriggerDataResponse creates an UpgradeTriggerDataResponse from a model func NewUpgradeTriggerDataResponse(t *models.UpgradeTrigger) *UpgradeTriggerDataResponse { var promoHTML *string if t.PromoHTML != "" { promoHTML = &t.PromoHTML } return &UpgradeTriggerDataResponse{ Title: t.Title, Message: t.Message, PromoHTML: promoHTML, ButtonText: t.ButtonText, } } // FeatureBenefitResponse represents a feature benefit type FeatureBenefitResponse struct { FeatureName string `json:"feature_name"` FreeTierText string `json:"free_tier_text"` ProTierText string `json:"pro_tier_text"` DisplayOrder int `json:"display_order"` } // NewFeatureBenefitResponse creates a FeatureBenefitResponse from a model func NewFeatureBenefitResponse(f *models.FeatureBenefit) *FeatureBenefitResponse { return &FeatureBenefitResponse{ FeatureName: f.FeatureName, FreeTierText: f.FreeTierText, ProTierText: f.ProTierText, DisplayOrder: f.DisplayOrder, } } // PromotionResponse represents a promotion type PromotionResponse struct { PromotionID string `json:"promotion_id"` Title string `json:"title"` Message string `json:"message"` Link *string `json:"link"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` } // NewPromotionResponse creates a PromotionResponse from a model func NewPromotionResponse(p *models.Promotion) *PromotionResponse { return &PromotionResponse{ PromotionID: p.PromotionID, Title: p.Title, Message: p.Message, Link: p.Link, StartDate: p.StartDate.Format("2006-01-02"), EndDate: p.EndDate.Format("2006-01-02"), } } // === Request Types === // ProcessPurchaseRequest represents an IAP purchase request type ProcessPurchaseRequest struct { ReceiptData string `json:"receipt_data"` // iOS (StoreKit 1 receipt or StoreKit 2 JWS) TransactionID string `json:"transaction_id"` // iOS StoreKit 2 transaction ID PurchaseToken string `json:"purchase_token"` // Android ProductID string `json:"product_id"` // Android (optional, helps identify subscription) Platform string `json:"platform" validate:"required,oneof=ios android stripe"` }