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:
@@ -12,11 +12,20 @@ const (
|
||||
TierPro SubscriptionTier = "pro"
|
||||
)
|
||||
|
||||
// SubscriptionPlatform constants
|
||||
const (
|
||||
PlatformIOS = "ios"
|
||||
PlatformAndroid = "android"
|
||||
PlatformStripe = "stripe"
|
||||
)
|
||||
|
||||
// SubscriptionSettings represents the subscription_subscriptionsettings table (singleton)
|
||||
type SubscriptionSettings struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
EnableLimitations bool `gorm:"column:enable_limitations;default:false" json:"enable_limitations"`
|
||||
EnableMonitoring bool `gorm:"column:enable_monitoring;default:true" json:"enable_monitoring"`
|
||||
TrialEnabled bool `gorm:"column:trial_enabled;default:true" json:"trial_enabled"`
|
||||
TrialDurationDays int `gorm:"column:trial_duration_days;default:14" json:"trial_duration_days"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
@@ -31,18 +40,28 @@ type UserSubscription struct {
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Tier SubscriptionTier `gorm:"column:tier;size:10;default:'free'" json:"tier"`
|
||||
|
||||
// In-App Purchase data
|
||||
AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"`
|
||||
// In-App Purchase data (Apple / Google)
|
||||
AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"`
|
||||
GooglePurchaseToken *string `gorm:"column:google_purchase_token;type:text" json:"-"`
|
||||
|
||||
// Stripe data (web subscriptions)
|
||||
StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"`
|
||||
StripeSubscriptionID *string `gorm:"column:stripe_subscription_id;size:255" json:"-"`
|
||||
StripePriceID *string `gorm:"column:stripe_price_id;size:255" json:"-"`
|
||||
|
||||
// Subscription dates
|
||||
SubscribedAt *time.Time `gorm:"column:subscribed_at" json:"subscribed_at"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at"`
|
||||
AutoRenew bool `gorm:"column:auto_renew;default:true" json:"auto_renew"`
|
||||
|
||||
// Trial
|
||||
TrialStart *time.Time `gorm:"column:trial_start" json:"trial_start"`
|
||||
TrialEnd *time.Time `gorm:"column:trial_end" json:"trial_end"`
|
||||
TrialUsed bool `gorm:"column:trial_used;default:false" json:"trial_used"`
|
||||
|
||||
// Tracking
|
||||
CancelledAt *time.Time `gorm:"column:cancelled_at" json:"cancelled_at"`
|
||||
Platform string `gorm:"column:platform;size:10" json:"platform"` // ios, android
|
||||
Platform string `gorm:"column:platform;size:10" json:"platform"` // ios, android, stripe
|
||||
|
||||
// Admin override - bypasses all limitations regardless of global settings
|
||||
IsFree bool `gorm:"column:is_free;default:false" json:"is_free"`
|
||||
@@ -53,8 +72,11 @@ func (UserSubscription) TableName() string {
|
||||
return "subscription_usersubscription"
|
||||
}
|
||||
|
||||
// IsActive returns true if the subscription is active (pro tier and not expired)
|
||||
// IsActive returns true if the subscription is active (pro tier and not expired, or trial active)
|
||||
func (s *UserSubscription) IsActive() bool {
|
||||
if s.IsTrialActive() {
|
||||
return true
|
||||
}
|
||||
if s.Tier != TierPro {
|
||||
return false
|
||||
}
|
||||
@@ -64,9 +86,37 @@ func (s *UserSubscription) IsActive() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// IsPro returns true if the user has a pro subscription
|
||||
// IsPro returns true if the user has a pro subscription or active trial
|
||||
func (s *UserSubscription) IsPro() bool {
|
||||
return s.Tier == TierPro && s.IsActive()
|
||||
return s.IsActive()
|
||||
}
|
||||
|
||||
// IsTrialActive returns true if the user has an active, unexpired trial
|
||||
func (s *UserSubscription) IsTrialActive() bool {
|
||||
if s.TrialEnd == nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().Before(*s.TrialEnd)
|
||||
}
|
||||
|
||||
// HasStripeSubscription returns true if the user has Stripe subscription data
|
||||
func (s *UserSubscription) HasStripeSubscription() bool {
|
||||
return s.StripeSubscriptionID != nil && *s.StripeSubscriptionID != ""
|
||||
}
|
||||
|
||||
// HasAppleSubscription returns true if the user has Apple receipt data
|
||||
func (s *UserSubscription) HasAppleSubscription() bool {
|
||||
return s.AppleReceiptData != nil && *s.AppleReceiptData != ""
|
||||
}
|
||||
|
||||
// HasGoogleSubscription returns true if the user has Google purchase token
|
||||
func (s *UserSubscription) HasGoogleSubscription() bool {
|
||||
return s.GooglePurchaseToken != nil && *s.GooglePurchaseToken != ""
|
||||
}
|
||||
|
||||
// SubscriptionSource returns the platform that the active subscription came from
|
||||
func (s *UserSubscription) SubscriptionSource() string {
|
||||
return s.Platform
|
||||
}
|
||||
|
||||
// UpgradeTrigger represents the subscription_upgradetrigger table
|
||||
|
||||
187
internal/models/subscription_test.go
Normal file
187
internal/models/subscription_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsTrialActive(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
future := now.Add(24 * time.Hour)
|
||||
past := now.Add(-24 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sub *UserSubscription
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "trial_end in future returns true",
|
||||
sub: &UserSubscription{TrialEnd: &future},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "trial_end in past returns false",
|
||||
sub: &UserSubscription{TrialEnd: &past},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "trial_end nil returns false",
|
||||
sub: &UserSubscription{TrialEnd: nil},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.sub.IsTrialActive()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPro(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
future := now.Add(24 * time.Hour)
|
||||
past := now.Add(-24 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sub *UserSubscription
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "tier=pro, expires_at in future returns true",
|
||||
sub: &UserSubscription{
|
||||
Tier: TierPro,
|
||||
ExpiresAt: &future,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "tier=pro, expires_at in past returns false",
|
||||
sub: &UserSubscription{
|
||||
Tier: TierPro,
|
||||
ExpiresAt: &past,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "tier=free, trial active returns true",
|
||||
sub: &UserSubscription{
|
||||
Tier: TierFree,
|
||||
TrialEnd: &future,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "tier=free, trial expired returns false",
|
||||
sub: &UserSubscription{
|
||||
Tier: TierFree,
|
||||
TrialEnd: &past,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "tier=free, no trial returns false",
|
||||
sub: &UserSubscription{
|
||||
Tier: TierFree,
|
||||
TrialEnd: nil,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.sub.IsPro()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSubscriptionHelpers(t *testing.T) {
|
||||
empty := ""
|
||||
validStripeID := "sub_1234567890"
|
||||
validReceipt := "MIIT..."
|
||||
validToken := "google-purchase-token-123"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sub *UserSubscription
|
||||
method string
|
||||
expected bool
|
||||
}{
|
||||
// HasStripeSubscription
|
||||
{
|
||||
name: "HasStripeSubscription with nil returns false",
|
||||
sub: &UserSubscription{StripeSubscriptionID: nil},
|
||||
method: "stripe",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HasStripeSubscription with empty string returns false",
|
||||
sub: &UserSubscription{StripeSubscriptionID: &empty},
|
||||
method: "stripe",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HasStripeSubscription with valid ID returns true",
|
||||
sub: &UserSubscription{StripeSubscriptionID: &validStripeID},
|
||||
method: "stripe",
|
||||
expected: true,
|
||||
},
|
||||
// HasAppleSubscription
|
||||
{
|
||||
name: "HasAppleSubscription with nil returns false",
|
||||
sub: &UserSubscription{AppleReceiptData: nil},
|
||||
method: "apple",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HasAppleSubscription with empty string returns false",
|
||||
sub: &UserSubscription{AppleReceiptData: &empty},
|
||||
method: "apple",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HasAppleSubscription with valid receipt returns true",
|
||||
sub: &UserSubscription{AppleReceiptData: &validReceipt},
|
||||
method: "apple",
|
||||
expected: true,
|
||||
},
|
||||
// HasGoogleSubscription
|
||||
{
|
||||
name: "HasGoogleSubscription with nil returns false",
|
||||
sub: &UserSubscription{GooglePurchaseToken: nil},
|
||||
method: "google",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HasGoogleSubscription with empty string returns false",
|
||||
sub: &UserSubscription{GooglePurchaseToken: &empty},
|
||||
method: "google",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HasGoogleSubscription with valid token returns true",
|
||||
sub: &UserSubscription{GooglePurchaseToken: &validToken},
|
||||
method: "google",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var result bool
|
||||
switch tt.method {
|
||||
case "stripe":
|
||||
result = tt.sub.HasStripeSubscription()
|
||||
case "apple":
|
||||
result = tt.sub.HasAppleSubscription()
|
||||
case "google":
|
||||
result = tt.sub.HasGoogleSubscription()
|
||||
}
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user