9bee436e86
GET /api/subscription/status/ was the slowest endpoint in the API at p50≈1750ms / p95≈2425ms — about 12× the floor for our cluster→Neon geography. Jaeger traces showed seven sequential SQL queries each costing roughly one transatlantic RTT (~110ms), with the actual queries running in 0.073ms at the database. Pure network serialization, not slow SQL. Three changes, in order of leverage: 1. Cache the assembled SubscriptionStatusResponse per-user in Redis with a 5-minute TTL. Hot path collapses to a single Redis GET (~5ms) on warm reads; the TTL is a safety net against missed invalidations. 2. Parallelize the three independent COUNT queries in getUserUsage (task_task / task_contractor / task_document) via golang.org/x/sync errgroup. Three RTTs collapse to one. Also dropped the redundant residence_residence COUNT — len(residenceIDs) from FindResidenceIDsByOwner is the same number, no need to re-query. 3. Wire explicit invalidation into every mutation that could change a user's response — residence/task/contractor/document CRUD, residence membership changes (JoinWithCode, RemoveUser, DeleteResidence), and every subscription tier flip across the IAP/Stripe/webhook surface. Residence-scoped invalidations fan out to every user with access via a new ResidenceRepository.FindUserIDsByResidence helper, so members of a shared residence don't see stale `usage` numbers when another member adds a task. Net effect: warm path goes from ~1350ms to ~5ms (Redis hit). Cold path goes from ~1350ms to ~250-450ms (5 sequential queries → 2 phases: residence IDs lookup, then parallel task/contractor/document counts). Also fixed a pre-existing CheckLimit signature drift in internal/integration/subscription_is_free_test.go that was blocking the package build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
782 lines
27 KiB
Go
782 lines
27 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/sync/errgroup"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/config"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-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.honeyDue.pro.monthly",
|
|
"com.tt.honeyDue.pro.yearly",
|
|
"honeydue_pro_monthly",
|
|
"honeydue_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
|
|
cache *CacheService
|
|
}
|
|
|
|
// SetCacheService wires Redis caching for SubscriptionSettings reads.
|
|
func (s *SubscriptionService) SetCacheService(cache *CacheService) {
|
|
s.cache = cache
|
|
}
|
|
|
|
// 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(ctx context.Context, userID uint) (*SubscriptionResponse, error) {
|
|
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
return NewSubscriptionResponse(sub), nil
|
|
}
|
|
|
|
// GetSubscriptionStatus gets detailed subscription status including limits.
|
|
//
|
|
// Hot path on the iOS launch screen — runs 7+ sequential SQL queries against
|
|
// transatlantic Neon Postgres at ~110ms RTT each (~800ms floor before
|
|
// optimization). The assembled response is cached per-user in Redis with a
|
|
// 5-minute TTL; mutation paths (residence/task/contractor/document/sub CRUD)
|
|
// invalidate via cache.InvalidateSubscriptionStatusForUsers, fanning out to
|
|
// every member of a shared residence.
|
|
func (s *SubscriptionService) GetSubscriptionStatus(ctx context.Context, userID uint) (*SubscriptionStatusResponse, error) {
|
|
// Cache fast path — only used on warm reads. Cold reads, trial-start
|
|
// branch, and the actual mutation paths below all populate fresh.
|
|
if s.cache != nil {
|
|
var cached SubscriptionStatusResponse
|
|
if err := s.cache.GetCachedSubscriptionStatus(ctx, userID, &cached); err == nil {
|
|
return &cached, nil
|
|
}
|
|
}
|
|
|
|
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
settings, err := cachedSubscriptionSettings(ctx, s.cache, s.subscriptionRepo)
|
|
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.WithContext(ctx).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.WithContext(ctx).FindByUserID(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
}
|
|
|
|
// Get all tier limits and build a map
|
|
allLimits, err := s.subscriptionRepo.WithContext(ctx).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(ctx, 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()
|
|
|
|
// Best-effort cache write. Errors are logged at the cache layer, not fatal.
|
|
if s.cache != nil {
|
|
_ = s.cache.CacheSubscriptionStatus(ctx, userID, resp)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// getUserUsage calculates current usage for a user.
|
|
//
|
|
// Performance: residence ID lookup is one query (we use len() for the
|
|
// properties count instead of a redundant COUNT). The three IN-clause counts
|
|
// against task_task / task_contractor / task_document don't depend on each
|
|
// other and run concurrently via errgroup, collapsing 3 transatlantic RTTs
|
|
// into 1. With residence IDs that's 2 RTT total instead of the prior 5.
|
|
func (s *SubscriptionService) getUserUsage(ctx context.Context, userID uint) (*UsageResponse, error) {
|
|
// One query — used both for the properties count (len) and as the IN-list
|
|
// for the three downstream counts. Replaces the prior CountByOwner +
|
|
// FindResidenceIDsByOwner pair, which queried residence_residence twice
|
|
// with the same predicate.
|
|
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByOwner(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
var (
|
|
tasksCount int64
|
|
contractorsCount int64
|
|
documentsCount int64
|
|
)
|
|
|
|
g, gCtx := errgroup.WithContext(ctx)
|
|
g.Go(func() error {
|
|
c, err := s.taskRepo.WithContext(gCtx).CountByResidenceIDs(residenceIDs)
|
|
tasksCount = c
|
|
return err
|
|
})
|
|
g.Go(func() error {
|
|
c, err := s.contractorRepo.WithContext(gCtx).CountByResidenceIDs(residenceIDs)
|
|
contractorsCount = c
|
|
return err
|
|
})
|
|
g.Go(func() error {
|
|
c, err := s.documentRepo.WithContext(gCtx).CountByResidenceIDs(residenceIDs)
|
|
documentsCount = c
|
|
return err
|
|
})
|
|
if err := g.Wait(); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &UsageResponse{
|
|
PropertiesCount: int64(len(residenceIDs)),
|
|
TasksCount: tasksCount,
|
|
ContractorsCount: contractorsCount,
|
|
DocumentsCount: documentsCount,
|
|
}, nil
|
|
}
|
|
|
|
// CheckLimit checks if a user has exceeded a specific limit
|
|
func (s *SubscriptionService) CheckLimit(ctx context.Context, userID uint, limitType string) error {
|
|
settings, err := cachedSubscriptionSettings(ctx, s.cache, s.subscriptionRepo)
|
|
if err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// If limitations are disabled globally, allow everything
|
|
if !settings.EnableLimitations {
|
|
return nil
|
|
}
|
|
|
|
sub, err := s.subscriptionRepo.WithContext(ctx).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.WithContext(ctx).GetTierLimits(sub.Tier)
|
|
if err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
usage, err := s.getUserUsage(ctx, 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(ctx context.Context, key string) (*UpgradeTriggerResponse, error) {
|
|
trigger, err := s.subscriptionRepo.WithContext(ctx).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(ctx context.Context) (map[string]*UpgradeTriggerDataResponse, error) {
|
|
triggers, err := s.subscriptionRepo.WithContext(ctx).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(ctx context.Context) ([]FeatureBenefitResponse, error) {
|
|
benefits, err := s.subscriptionRepo.WithContext(ctx).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(ctx context.Context, userID uint) ([]PromotionResponse, error) {
|
|
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
promotions, err := s.subscriptionRepo.WithContext(ctx).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(ctx context.Context, userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) {
|
|
// Store receipt/transaction data
|
|
dataToStore := receiptData
|
|
if dataToStore == "" {
|
|
dataToStore = transactionID
|
|
}
|
|
if err := s.subscriptionRepo.WithContext(ctx).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.WithContext(ctx).UpgradeToPro(userID, expiresAt, "ios"); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Tier flipped — drop cached SubscriptionStatusResponse so the next call
|
|
// returns Pro immediately instead of stale Free.
|
|
if s.cache != nil {
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
|
}
|
|
|
|
return s.GetSubscription(ctx, userID)
|
|
}
|
|
|
|
// ProcessGooglePurchase processes a Google Play purchase
|
|
// productID is optional but helps validate the specific subscription
|
|
func (s *SubscriptionService) ProcessGooglePurchase(ctx context.Context, userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
|
|
// Store purchase token first
|
|
if err := s.subscriptionRepo.WithContext(ctx).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.WithContext(ctx).UpgradeToPro(userID, expiresAt, "android"); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
if s.cache != nil {
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
|
}
|
|
|
|
return s.GetSubscription(ctx, userID)
|
|
}
|
|
|
|
// CancelSubscription cancels a subscription (downgrades to free at end of period)
|
|
func (s *SubscriptionService) CancelSubscription(ctx context.Context, userID uint) (*SubscriptionResponse, error) {
|
|
if err := s.subscriptionRepo.WithContext(ctx).SetAutoRenew(userID, false); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
// auto_renew flips a field surfaced in SubscriptionStatusResponse.
|
|
if s.cache != nil {
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
|
}
|
|
return s.GetSubscription(ctx, 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(ctx context.Context, userID uint, requestedPlatform string) (bool, string, error) {
|
|
sub, err := s.subscriptionRepo.WithContext(ctx).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,
|
|
}
|
|
}
|
|
|
|
// invalidateSubStatusForResidence drops the per-user subscription_status cache
|
|
// for every user with access to a residence (owner + members from
|
|
// residence_residence_users). Used by every mutation that changes shared data
|
|
// counts — tasks, contractors, documents — so members of a shared residence
|
|
// don't see stale `usage` numbers.
|
|
//
|
|
// Best-effort: failures are logged but never returned. The 5-min cache TTL is
|
|
// the safety net if this ever silently fails.
|
|
func invalidateSubStatusForResidence(ctx context.Context, cache *CacheService, residenceRepo *repositories.ResidenceRepository, residenceID uint) {
|
|
if cache == nil {
|
|
return
|
|
}
|
|
userIDs, err := residenceRepo.FindUserIDsByResidence(residenceID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Uint("residence_id", residenceID).Msg("sub_status invalidation: residence lookup failed")
|
|
return
|
|
}
|
|
if len(userIDs) == 0 {
|
|
return
|
|
}
|
|
if err := cache.InvalidateSubscriptionStatusForUsers(ctx, userIDs...); err != nil {
|
|
log.Warn().Err(err).Uint("residence_id", residenceID).Msg("sub_status invalidation: redis delete failed")
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
}
|