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

@@ -2,6 +2,7 @@ package services
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -179,3 +180,94 @@ func TestProcessGooglePurchase_ValidationFails_DoesNotUpgrade(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, models.TierFree, updatedSub.Tier, "User should remain on free tier")
}
func TestIsAlreadyProFromOtherPlatform(t *testing.T) {
future := time.Now().UTC().Add(30 * 24 * time.Hour)
tests := []struct {
name string
tier models.SubscriptionTier
platform string
expiresAt *time.Time
trialEnd *time.Time
requestedPlatform string
wantConflict bool
wantPlatform string
}{
{
name: "free user returns no conflict",
tier: models.TierFree,
platform: "",
expiresAt: nil,
trialEnd: nil,
requestedPlatform: "stripe",
wantConflict: false,
wantPlatform: "",
},
{
name: "pro from ios, requesting ios returns no conflict (same platform)",
tier: models.TierPro,
platform: "ios",
expiresAt: &future,
trialEnd: nil,
requestedPlatform: "ios",
wantConflict: false,
wantPlatform: "",
},
{
name: "pro from ios, requesting stripe returns conflict",
tier: models.TierPro,
platform: "ios",
expiresAt: &future,
trialEnd: nil,
requestedPlatform: "stripe",
wantConflict: true,
wantPlatform: "ios",
},
{
name: "pro from stripe, requesting android returns conflict",
tier: models.TierPro,
platform: "stripe",
expiresAt: &future,
trialEnd: nil,
requestedPlatform: "android",
wantConflict: true,
wantPlatform: "stripe",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := testutil.SetupTestDB(t)
subscriptionRepo := repositories.NewSubscriptionRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
svc := &SubscriptionService{
subscriptionRepo: subscriptionRepo,
residenceRepo: residenceRepo,
taskRepo: taskRepo,
contractorRepo: contractorRepo,
documentRepo: documentRepo,
}
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sub := &models.UserSubscription{
UserID: user.ID,
Tier: tt.tier,
Platform: tt.platform,
ExpiresAt: tt.expiresAt,
TrialEnd: tt.trialEnd,
}
err := db.Create(sub).Error
require.NoError(t, err)
conflict, existingPlatform, err := svc.IsAlreadyProFromOtherPlatform(user.ID, tt.requestedPlatform)
require.NoError(t, err)
assert.Equal(t, tt.wantConflict, conflict)
assert.Equal(t, tt.wantPlatform, existingPlatform)
})
}
}