Files
honeyDueAPI/internal/services/subscription_service.go
Trey t 7690f07a2b Harden API security: input validation, safe auth extraction, new tests, and deploy config
Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:48:01 -06:00

643 lines
21 KiB
Go

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)
}
// 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{
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.
// 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<String, UpgradeTriggerData>
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)
}
// === 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" validate:"required,oneof=ios android"`
}