Total rebrand across all Go API source files: - Go module path: casera-api -> honeydue-api - All imports updated (130+ files) - Docker: containers, images, networks renamed - Email templates: support email, noreply, icon URL - Domains: casera.app/mycrib.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - IAP product IDs updated - Landing page, admin panel, config defaults - Seeds, CI workflows, Makefile, docs - Database table names preserved (no migration needed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
703 lines
23 KiB
Go
703 lines
23 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(userID uint) (*SubscriptionResponse, error) {
|
|
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
return NewSubscriptionResponse(sub), nil
|
|
}
|
|
|
|
// GetSubscriptionStatus gets detailed subscription status including limits
|
|
func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionStatusResponse, error) {
|
|
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
settings, err := s.subscriptionRepo.GetSettings()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// 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.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.FindByUserID(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
}
|
|
|
|
// Get all tier limits and build a map
|
|
allLimits, err := s.subscriptionRepo.GetAllTierLimits()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
limitsMap := make(map[string]*TierLimitsClientResponse)
|
|
for _, l := range allLimits {
|
|
limitsMap[string(l.Tier)] = NewTierLimitsClientResponse(&l)
|
|
}
|
|
|
|
// Ensure both free and pro exist with defaults if missing
|
|
if _, ok := limitsMap["free"]; !ok {
|
|
defaults := models.GetDefaultFreeLimits()
|
|
limitsMap["free"] = NewTierLimitsClientResponse(&defaults)
|
|
}
|
|
if _, ok := limitsMap["pro"]; !ok {
|
|
defaults := models.GetDefaultProLimits()
|
|
limitsMap["pro"] = NewTierLimitsClientResponse(&defaults)
|
|
}
|
|
|
|
// Get current usage
|
|
usage, err := s.getUserUsage(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Determine if limitations are enabled for this user
|
|
// If user has IsFree flag, always return false (no limitations)
|
|
limitationsEnabled := settings.EnableLimitations
|
|
if sub.IsFree {
|
|
limitationsEnabled = false
|
|
}
|
|
|
|
// Build flattened response (KMM expects subscription fields at top level)
|
|
resp := &SubscriptionStatusResponse{
|
|
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.
|
|
// Uses batch COUNT queries (O(1) queries) instead of per-residence queries (O(N)).
|
|
func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) {
|
|
residences, err := s.residenceRepo.FindOwnedByUser(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
propertiesCount := int64(len(residences))
|
|
|
|
// Collect residence IDs for batch queries
|
|
residenceIDs := make([]uint, len(residences))
|
|
for i, r := range residences {
|
|
residenceIDs[i] = r.ID
|
|
}
|
|
|
|
// Count tasks, contractors, and documents across all residences with single queries each
|
|
tasksCount, err := s.taskRepo.CountByResidenceIDs(residenceIDs)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
contractorsCount, err := s.contractorRepo.CountByResidenceIDs(residenceIDs)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
documentsCount, err := s.documentRepo.CountByResidenceIDs(residenceIDs)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return &UsageResponse{
|
|
PropertiesCount: propertiesCount,
|
|
TasksCount: tasksCount,
|
|
ContractorsCount: contractorsCount,
|
|
DocumentsCount: documentsCount,
|
|
}, nil
|
|
}
|
|
|
|
// CheckLimit checks if a user has exceeded a specific limit
|
|
func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
|
|
settings, err := s.subscriptionRepo.GetSettings()
|
|
if err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// If limitations are disabled globally, allow everything
|
|
if !settings.EnableLimitations {
|
|
return nil
|
|
}
|
|
|
|
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
|
if err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// IsFree users bypass all limitations
|
|
if sub.IsFree {
|
|
return nil
|
|
}
|
|
|
|
// Pro users have unlimited access
|
|
if sub.IsPro() {
|
|
return nil
|
|
}
|
|
|
|
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
|
|
if err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
usage, err := s.getUserUsage(userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch limitType {
|
|
case "properties":
|
|
if limits.PropertiesLimit != nil && usage.PropertiesCount >= int64(*limits.PropertiesLimit) {
|
|
return apperrors.Forbidden("error.properties_limit_exceeded")
|
|
}
|
|
case "tasks":
|
|
if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) {
|
|
return apperrors.Forbidden("error.tasks_limit_exceeded")
|
|
}
|
|
case "contractors":
|
|
if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) {
|
|
return apperrors.Forbidden("error.contractors_limit_exceeded")
|
|
}
|
|
case "documents":
|
|
if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) {
|
|
return apperrors.Forbidden("error.documents_limit_exceeded")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetUpgradeTrigger gets an upgrade trigger by key
|
|
func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResponse, error) {
|
|
trigger, err := s.subscriptionRepo.GetUpgradeTrigger(key)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.upgrade_trigger_not_found")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
return NewUpgradeTriggerResponse(trigger), nil
|
|
}
|
|
|
|
// GetAllUpgradeTriggers gets all active upgrade triggers as a map keyed by trigger_key
|
|
// KMM client expects Map<String, UpgradeTriggerData>
|
|
func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) {
|
|
triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make(map[string]*UpgradeTriggerDataResponse)
|
|
for _, t := range triggers {
|
|
result[t.TriggerKey] = NewUpgradeTriggerDataResponse(&t)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetFeatureBenefits gets all feature benefits
|
|
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
|
|
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]FeatureBenefitResponse, len(benefits))
|
|
for i, b := range benefits {
|
|
result[i] = *NewFeatureBenefitResponse(&b)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetActivePromotions gets active promotions for a user
|
|
func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionResponse, error) {
|
|
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
promotions, err := s.subscriptionRepo.GetActivePromotions(sub.Tier)
|
|
if err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
result := make([]PromotionResponse, len(promotions))
|
|
for i, p := range promotions {
|
|
result[i] = *NewPromotionResponse(&p)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ProcessApplePurchase processes an Apple IAP purchase
|
|
// Supports both StoreKit 1 (receiptData) and StoreKit 2 (transactionID)
|
|
func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData string, transactionID string) (*SubscriptionResponse, error) {
|
|
// Store receipt/transaction data
|
|
dataToStore := receiptData
|
|
if dataToStore == "" {
|
|
dataToStore = transactionID
|
|
}
|
|
if err := s.subscriptionRepo.UpdateReceiptData(userID, dataToStore); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Apple IAP client must be configured to validate purchases.
|
|
// Without server-side validation, we cannot trust client-provided receipts.
|
|
if s.appleClient == nil {
|
|
log.Error().Uint("user_id", userID).Msg("Apple IAP validation not configured, rejecting purchase")
|
|
return nil, apperrors.BadRequest("error.iap_validation_not_configured")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
var result *AppleValidationResult
|
|
var err error
|
|
|
|
// Prefer transaction ID (StoreKit 2), fall back to receipt data (StoreKit 1)
|
|
if transactionID != "" {
|
|
result, err = s.appleClient.ValidateTransaction(ctx, transactionID)
|
|
} else if receiptData != "" {
|
|
result, err = s.appleClient.ValidateReceipt(ctx, receiptData)
|
|
}
|
|
|
|
if err != nil {
|
|
// Validation failed -- do NOT fall through to grant Pro.
|
|
log.Error().Err(err).Uint("user_id", userID).Msg("Apple validation failed")
|
|
return nil, err
|
|
}
|
|
|
|
if result == nil {
|
|
return nil, apperrors.BadRequest("error.no_receipt_or_transaction")
|
|
}
|
|
|
|
expiresAt := result.ExpiresAt
|
|
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Str("env", result.Environment).Msg("Apple purchase validated")
|
|
|
|
// Upgrade to Pro with the validated expiration
|
|
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return s.GetSubscription(userID)
|
|
}
|
|
|
|
// ProcessGooglePurchase processes a Google Play purchase
|
|
// productID is optional but helps validate the specific subscription
|
|
func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
|
|
// Store purchase token first
|
|
if err := s.subscriptionRepo.UpdatePurchaseToken(userID, purchaseToken); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
// Google IAP client must be configured to validate purchases.
|
|
// Without server-side validation, we cannot trust client-provided tokens.
|
|
if s.googleClient == nil {
|
|
log.Error().Uint("user_id", userID).Msg("Google IAP validation not configured, rejecting purchase")
|
|
return nil, apperrors.BadRequest("error.iap_validation_not_configured")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
var result *GoogleValidationResult
|
|
var err error
|
|
|
|
// If productID is provided, use it directly; otherwise try known IDs
|
|
if productID != "" {
|
|
result, err = s.googleClient.ValidateSubscription(ctx, productID, purchaseToken)
|
|
} else {
|
|
result, err = s.googleClient.ValidatePurchaseToken(ctx, purchaseToken, KnownSubscriptionIDs)
|
|
}
|
|
|
|
if err != nil {
|
|
// Validation failed -- do NOT fall through to grant Pro.
|
|
log.Error().Err(err).Uint("user_id", userID).Msg("Google purchase validation failed")
|
|
return nil, err
|
|
}
|
|
|
|
if result == nil {
|
|
return nil, apperrors.BadRequest("error.no_purchase_token")
|
|
}
|
|
|
|
expiresAt := result.ExpiresAt
|
|
log.Info().Uint("user_id", userID).Str("product", result.ProductID).Time("expires", result.ExpiresAt).Bool("auto_renew", result.AutoRenewing).Msg("Google purchase validated")
|
|
|
|
// Acknowledge the subscription if not already acknowledged
|
|
if !result.AcknowledgedState {
|
|
if err := s.googleClient.AcknowledgeSubscription(ctx, result.ProductID, purchaseToken); err != nil {
|
|
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to acknowledge Google subscription")
|
|
// Don't fail the purchase, just log the warning
|
|
}
|
|
}
|
|
|
|
// Upgrade to Pro with the validated expiration
|
|
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
|
|
return s.GetSubscription(userID)
|
|
}
|
|
|
|
// CancelSubscription cancels a subscription (downgrades to free at end of period)
|
|
func (s *SubscriptionService) CancelSubscription(userID uint) (*SubscriptionResponse, error) {
|
|
if err := s.subscriptionRepo.SetAutoRenew(userID, false); err != nil {
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
return s.GetSubscription(userID)
|
|
}
|
|
|
|
// 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(userID uint, requestedPlatform string) (bool, string, error) {
|
|
sub, err := s.subscriptionRepo.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"`
|
|
}
|