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:
Trey t
2026-03-05 11:36:14 -06:00
parent d5bb123cd0
commit 72db9050f8
35 changed files with 1555 additions and 1120 deletions

View File

@@ -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

View 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)
})
}
}