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:
@@ -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)
|
||||
// ====================
|
||||
|
||||
Reference in New Issue
Block a user