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

@@ -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)
// ====================