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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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