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