b67f7f9e6b
Trace data revealed subscription_subscriptionsettings was consuming
1,983s of cumulative DB time per day (180× more than the next-largest
table) for a 32-byte singleton row of admin-toggleable global flags.
Root cause was a 30-second poll loop in monitoring.Service per pod
plus uncached reads on every authed status check / CreateResidence /
Stripe webhook. Fix is layered:
1. Redis cache for SubscriptionSettings — same shape as the
residence-IDs cache. 30-min TTL, explicit invalidation on admin
write. New CacheService.{Cache,GetCached,Invalidate}SubscriptionSettings
plus a cachedSubscriptionSettings helper in services/.
2. SubscriptionService, StripeService, and both admin handlers
(settings + limitations) now read through the cache. Admin write
handlers invalidate so toggles propagate cluster-wide within ms
instead of waiting for the TTL.
3. monitoring.Service.syncSettingsFromDB also reads from Redis first
(raw redis.Client to avoid a services→monitoring import cycle).
Polling interval bumped 30s → 5min. Combined with Redis-shared
cache, cluster-wide DB hits from this poll go from ~480/hour to
~2/hour — a 240× reduction.
4. StripeService.CreateCheckoutSession now takes ctx so the cached
settings span (and the Stripe webhook trace) stay attached to the
request. Handler call site updated.
5. Admin handlers' direct h.db.First calls switched to
db.WithContext(ctx) so the resulting orphan SQL spans nest under
the admin request span in Jaeger.
Net DB query rate for subscription_subscriptionsettings should drop
from 0.101/sec to ~0/sec with occasional invalidation-driven refills,
and the table's cumulative DB time from 1,983s/day to ~10s/day.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
710 lines
24 KiB
Go
710 lines
24 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"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
|
|
func (s *SubscriptionService) GetSubscriptionStatus(ctx context.Context, userID uint) (*SubscriptionStatusResponse, error) {
|
|
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()
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// getUserUsage calculates current usage for a user.
|
|
// P-10: Uses CountByOwner for properties count instead of loading all owned residences.
|
|
// Uses batch COUNT queries (O(1) queries) instead of per-residence queries (O(N)).
|
|
func (s *SubscriptionService) getUserUsage(ctx context.Context, userID uint) (*UsageResponse, error) {
|
|
// P-10: Use CountByOwner for an efficient COUNT query instead of loading all records
|
|
propertiesCount, err := s.residenceRepo.WithContext(ctx).CountByOwner(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Still need residence IDs for batch counting tasks/contractors/documents
|
|
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByOwner(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Count tasks, contractors, and documents across all residences with single queries each
|
|
tasksCount, err := s.taskRepo.WithContext(ctx).CountByResidenceIDs(residenceIDs)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
contractorsCount, err := s.contractorRepo.WithContext(ctx).CountByResidenceIDs(residenceIDs)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
documentsCount, err := s.documentRepo.WithContext(ctx).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(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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
}
|