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

@@ -240,7 +240,7 @@ func TestSubscriptionHandler_NoAuth_Returns401(t *testing.T) {
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
handler := NewSubscriptionHandler(subscriptionService)
handler := NewSubscriptionHandler(subscriptionService, nil)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware

View File

@@ -13,11 +13,15 @@ import (
// SubscriptionHandler handles subscription-related HTTP requests
type SubscriptionHandler struct {
subscriptionService *services.SubscriptionService
stripeService *services.StripeService
}
// NewSubscriptionHandler creates a new subscription handler
func NewSubscriptionHandler(subscriptionService *services.SubscriptionService) *SubscriptionHandler {
return &SubscriptionHandler{subscriptionService: subscriptionService}
func NewSubscriptionHandler(subscriptionService *services.SubscriptionService, stripeService *services.StripeService) *SubscriptionHandler {
return &SubscriptionHandler{
subscriptionService: subscriptionService,
stripeService: stripeService,
}
}
// GetSubscription handles GET /api/subscription/
@@ -194,3 +198,82 @@ func (h *SubscriptionHandler) RestoreSubscription(c echo.Context) error {
"subscription": subscription,
})
}
// CreateCheckoutSession handles POST /api/subscription/checkout/
// Creates a Stripe Checkout Session for web subscription purchases
func (h *SubscriptionHandler) CreateCheckoutSession(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
if h.stripeService == nil {
return apperrors.BadRequest("error.stripe_not_configured")
}
// Check if already Pro from another platform
alreadyPro, existingPlatform, err := h.subscriptionService.IsAlreadyProFromOtherPlatform(user.ID, "stripe")
if err != nil {
return err
}
if alreadyPro {
return c.JSON(http.StatusConflict, map[string]interface{}{
"error": "error.already_subscribed_other_platform",
"existing_platform": existingPlatform,
"message": "You already have an active Pro subscription via " + existingPlatform + ". Manage it there to avoid double billing.",
})
}
var req struct {
PriceID string `json:"price_id" validate:"required"`
SuccessURL string `json:"success_url" validate:"required,url"`
CancelURL string `json:"cancel_url" validate:"required,url"`
}
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return err
}
sessionURL, err := h.stripeService.CreateCheckoutSession(user.ID, req.PriceID, req.SuccessURL, req.CancelURL)
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{
"checkout_url": sessionURL,
})
}
// CreatePortalSession handles POST /api/subscription/portal/
// Creates a Stripe Customer Portal session for managing web subscriptions
func (h *SubscriptionHandler) CreatePortalSession(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
if h.stripeService == nil {
return apperrors.BadRequest("error.stripe_not_configured")
}
var req struct {
ReturnURL string `json:"return_url" validate:"required,url"`
}
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return err
}
portalURL, err := h.stripeService.CreatePortalSession(user.ID, req.ReturnURL)
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{
"portal_url": portalURL,
})
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
)
// SubscriptionWebhookHandler handles subscription webhook callbacks
@@ -28,6 +29,7 @@ type SubscriptionWebhookHandler struct {
userRepo *repositories.UserRepository
webhookEventRepo *repositories.WebhookEventRepository
appleRootCerts []*x509.Certificate
stripeService *services.StripeService
enabled bool
}
@@ -46,6 +48,11 @@ func NewSubscriptionWebhookHandler(
}
}
// SetStripeService sets the Stripe service for webhook handling
func (h *SubscriptionWebhookHandler) SetStripeService(stripeService *services.StripeService) {
h.stripeService = stripeService
}
// ====================
// Apple App Store Server Notifications v2
// ====================
@@ -377,38 +384,30 @@ func (h *SubscriptionWebhookHandler) handleAppleFailedToRenew(userID uint, tx *A
}
func (h *SubscriptionWebhookHandler) handleAppleExpired(userID uint, tx *AppleTransactionInfo) error {
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
if err := h.safeDowngradeToFree(userID, "Apple expired"); err != nil {
return err
}
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User subscription expired, downgraded to free")
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleRefund(userID uint, tx *AppleTransactionInfo) error {
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
if err := h.safeDowngradeToFree(userID, "Apple refund"); err != nil {
return err
}
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User got refund, downgraded to free")
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleRevoke(userID uint, tx *AppleTransactionInfo) error {
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
if err := h.safeDowngradeToFree(userID, "Apple revoke"); err != nil {
return err
}
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User subscription revoked, downgraded to free")
return nil
}
func (h *SubscriptionWebhookHandler) handleAppleGracePeriodExpired(userID uint, tx *AppleTransactionInfo) error {
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
if err := h.safeDowngradeToFree(userID, "Apple grace period expired"); err != nil {
return err
}
log.Info().Uint("user_id", userID).Msg("Apple Webhook: User grace period expired, downgraded to free")
return nil
}
@@ -705,22 +704,16 @@ func (h *SubscriptionWebhookHandler) handleGoogleRestarted(userID uint, notifica
}
func (h *SubscriptionWebhookHandler) handleGoogleRevoked(userID uint, notification *GoogleSubscriptionNotification) error {
// Subscription revoked - immediate downgrade
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
if err := h.safeDowngradeToFree(userID, "Google revoke"); err != nil {
return err
}
log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription revoked")
return nil
}
func (h *SubscriptionWebhookHandler) handleGoogleExpired(userID uint, notification *GoogleSubscriptionNotification) error {
// Subscription expired
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
if err := h.safeDowngradeToFree(userID, "Google expired"); err != nil {
return err
}
log.Info().Uint("user_id", userID).Msg("Google Webhook: User subscription expired")
return nil
}
@@ -730,6 +723,88 @@ func (h *SubscriptionWebhookHandler) handleGooglePaused(userID uint, notificatio
return nil
}
// ====================
// Multi-Source Downgrade Safety
// ====================
// safeDowngradeToFree checks if the user has active subscriptions from other sources
// before downgrading to free. If another source is still active, skip the downgrade.
func (h *SubscriptionWebhookHandler) safeDowngradeToFree(userID uint, reason string) error {
sub, err := h.subscriptionRepo.FindByUserID(userID)
if err != nil {
log.Warn().Err(err).Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Could not find subscription for multi-source check, proceeding with downgrade")
return h.subscriptionRepo.DowngradeToFree(userID)
}
// Check if Stripe subscription is still active
if sub.HasStripeSubscription() && sub.Platform != models.PlatformStripe {
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Stripe subscription")
return nil
}
// Check if Apple subscription is still active (for Google/Stripe webhooks)
if sub.HasAppleSubscription() && sub.Platform != models.PlatformIOS {
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Apple subscription")
return nil
}
// Check if Google subscription is still active (for Apple/Stripe webhooks)
if sub.HasGoogleSubscription() && sub.Platform != models.PlatformAndroid {
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active Google subscription")
return nil
}
// Check if trial is still active
if sub.IsTrialActive() {
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: Skipping downgrade — user has active trial")
return nil
}
if err := h.subscriptionRepo.DowngradeToFree(userID); err != nil {
return err
}
log.Info().Uint("user_id", userID).Str("reason", reason).Msg("Webhook: User downgraded to free (no other active sources)")
return nil
}
// ====================
// Stripe Webhooks
// ====================
// HandleStripeWebhook handles POST /api/subscription/webhook/stripe/
func (h *SubscriptionWebhookHandler) HandleStripeWebhook(c echo.Context) error {
if !h.enabled {
log.Info().Msg("Stripe Webhook: webhooks disabled by feature flag")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "webhooks_disabled"})
}
if h.stripeService == nil {
log.Warn().Msg("Stripe Webhook: Stripe service not configured")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "not_configured"})
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
log.Error().Err(err).Msg("Stripe Webhook: Failed to read body")
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
}
signature := c.Request().Header.Get("Stripe-Signature")
if signature == "" {
log.Warn().Msg("Stripe Webhook: Missing Stripe-Signature header")
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "missing signature"})
}
if err := h.stripeService.HandleWebhookEvent(body, signature); err != nil {
log.Error().Err(err).Msg("Stripe Webhook: Failed to process webhook")
// Still return 200 to prevent Stripe from retrying on business logic errors
// Only return error for signature verification failures
if strings.Contains(err.Error(), "signature") {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid signature"})
}
}
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
}
// ====================
// Signature Verification (Optional but Recommended)
// ====================