Add Stripe billing, free trials, and cross-platform subscription guards
- Stripe integration: add StripeService with checkout sessions, customer portal, and webhook handling for subscription lifecycle events. - Free trials: auto-start configurable trial on first subscription check, with admin-controllable duration and enable/disable toggle. - Cross-platform guard: prevent duplicate subscriptions across iOS, Android, and Stripe by checking existing platform before allowing purchase. - Subscription model: add Stripe fields (customer_id, subscription_id, price_id), trial fields (trial_start, trial_end, trial_used), and SubscriptionSource/IsTrialActive helpers. - API: add trial and source fields to status response, update OpenAPI spec. - Clean up stale migration and audit docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,20 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
|
||||
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 {
|
||||
@@ -154,6 +168,8 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
|
||||
|
||||
// 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,
|
||||
@@ -170,6 +186,18 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
|
||||
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
|
||||
}
|
||||
|
||||
@@ -449,28 +477,48 @@ func (s *SubscriptionService) CancelSubscription(userID uint) (*SubscriptionResp
|
||||
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"`
|
||||
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(),
|
||||
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")
|
||||
@@ -536,11 +584,23 @@ func NewTierLimitsClientResponse(l *models.TierLimits) *TierLimitsClientResponse
|
||||
// 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"`
|
||||
@@ -638,5 +698,5 @@ type ProcessPurchaseRequest struct {
|
||||
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"`
|
||||
Platform string `json:"platform" validate:"required,oneof=ios android stripe"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user