package services import ( "context" "errors" "log" "time" "gorm.io/gorm" "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 ( ErrSubscriptionNotFound = errors.New("subscription not found") ErrPropertiesLimitExceeded = errors.New("properties limit exceeded for your subscription tier") ErrTasksLimitExceeded = errors.New("tasks limit exceeded for your subscription tier") ErrContractorsLimitExceeded = errors.New("contractors limit exceeded for your subscription tier") ErrDocumentsLimitExceeded = errors.New("documents limit exceeded for your subscription tier") ErrUpgradeTriggerNotFound = errors.New("upgrade trigger not found") 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.Printf("Warning: Failed to initialize Apple IAP client: %v", err) } } else { svc.appleClient = appleClient log.Println("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.Printf("Warning: Failed to initialize Google IAP client: %v", err) } } else { svc.googleClient = googleClient log.Println("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, 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, err } settings, err := s.subscriptionRepo.GetSettings() if err != nil { return nil, err } // Get all tier limits and build a map allLimits, err := s.subscriptionRepo.GetAllTierLimits() if err != nil { return nil, 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{ 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 } return resp, nil } // getUserUsage calculates current usage for a user func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) { residences, err := s.residenceRepo.FindOwnedByUser(userID) if err != nil { return nil, err } propertiesCount := int64(len(residences)) // Count tasks, contractors, and documents across all user's residences var tasksCount, contractorsCount, documentsCount int64 for _, r := range residences { tc, err := s.taskRepo.CountByResidence(r.ID) if err != nil { return nil, err } tasksCount += tc cc, err := s.contractorRepo.CountByResidence(r.ID) if err != nil { return nil, err } contractorsCount += cc dc, err := s.documentRepo.CountByResidence(r.ID) if err != nil { return nil, err } documentsCount += dc } 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 err } // If limitations are disabled globally, allow everything if !settings.EnableLimitations { return nil } sub, err := s.subscriptionRepo.GetOrCreate(userID) if err != nil { return 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 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 ErrPropertiesLimitExceeded } case "tasks": if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) { return ErrTasksLimitExceeded } case "contractors": if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) { return ErrContractorsLimitExceeded } case "documents": if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) { return ErrDocumentsLimitExceeded } } 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, ErrUpgradeTriggerNotFound } return nil, 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, 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, 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, err } promotions, err := s.subscriptionRepo.GetActivePromotions(sub.Tier) if err != nil { return nil, 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, err } // Validate with Apple if client is configured var expiresAt time.Time if s.appleClient != nil { 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 { // Log the validation error log.Printf("Apple validation warning for user %d: %v", userID, err) // Check if it's a fatal error if errors.Is(err, ErrInvalidReceipt) || errors.Is(err, ErrSubscriptionCancelled) { return nil, err } // For other errors (network, etc.), fall back with shorter expiry expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback } else if result != nil { // Use the expiration date from Apple expiresAt = result.ExpiresAt log.Printf("Apple purchase validated for user %d: product=%s, expires=%v, env=%s", userID, result.ProductID, result.ExpiresAt, result.Environment) } } else { // Apple validation not configured - trust client but log warning log.Printf("Warning: Apple IAP validation not configured, trusting client for user %d", userID) expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default } // Upgrade to Pro with the determined expiration if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil { return nil, 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, err } // Validate the purchase with Google if client is configured var expiresAt time.Time if s.googleClient != nil { 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 { // Log the validation error log.Printf("Google purchase validation warning for user %d: %v", userID, err) // Check if it's a fatal error if errors.Is(err, ErrInvalidPurchaseToken) || errors.Is(err, ErrSubscriptionCancelled) { return nil, err } if errors.Is(err, ErrSubscriptionExpired) { // Subscription expired - still allow but set past expiry expiresAt = time.Now().UTC().Add(-1 * time.Hour) } else { // For other errors, fall back with shorter expiry expiresAt = time.Now().UTC().AddDate(0, 1, 0) // 1 month fallback } } else if result != nil { // Use the expiration date from Google expiresAt = result.ExpiresAt log.Printf("Google purchase validated for user %d: product=%s, expires=%v, autoRenew=%v", userID, result.ProductID, result.ExpiresAt, result.AutoRenewing) // Acknowledge the subscription if not already acknowledged if !result.AcknowledgedState { if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil { log.Printf("Warning: Failed to acknowledge subscription for user %d: %v", userID, err) // Don't fail the purchase, just log the warning } } } } else { // Google validation not configured - trust client but log warning log.Printf("Warning: Google IAP validation not configured, trusting client for user %d", userID) expiresAt = time.Now().UTC().AddDate(1, 0, 0) // 1 year default } // Upgrade to Pro with the determined expiration if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil { return nil, 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, err } return s.GetSubscription(userID) } // === 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"` } // 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(), } 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 { // Flattened subscription fields (KMM expects these at top level) SubscribedAt *string `json:"subscribed_at"` ExpiresAt *string `json:"expires_at"` AutoRenew bool `json:"auto_renew"` // 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" binding:"required,oneof=ios android"` }