e881d37de0
Every public method on these five services now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With TaskService and ResidenceService already migrated, this means every in-process service that touches Postgres now produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span. Endpoints now fully traced (HTTP → service → SQL): - /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification - /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile - /api/contractors/* (CRUD + favorite + by-residence + tasks) - /api/documents/* (CRUD + activate/deactivate + image upload/delete) - /api/notifications/* (list, count, mark-read, prefs, devices) - /api/subscription/* (status, purchase, cancel, triggers, promotions) - All previously-migrated /api/tasks/* and /api/residences/* paths Internal helpers also threaded: - TaskService.sendTaskCompletedNotification → forwards ctx - TaskService.UpdateUserTimezone → forwards ctx to NotificationService - ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit - NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx ~75 method signatures, ~120 handler/test call sites updated. Tests pass green; the only failure is the pre-existing flaky TaskHandler_QuickComplete SQLite race that fails ~60% of runs on master. Step 3 of the observability plan is now genuinely complete: every API endpoint backed by a Go service emits a per-request flame graph with HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
704 lines
24 KiB
Go
704 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
|
|
}
|
|
|
|
// 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 := s.subscriptionRepo.WithContext(ctx).GetSettings()
|
|
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 := s.subscriptionRepo.WithContext(ctx).GetSettings()
|
|
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"`
|
|
}
|