Files
honeyDueAPI/internal/services/subscription_service.go
T
Trey t b67f7f9e6b
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Cache SubscriptionSettings + cut monitoring poll noise
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>
2026-04-26 21:29:30 -05:00

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"`
}