Files
honeyDueAPI/internal/handlers/subscription_handler.go
Trey t 72db9050f8 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>
2026-03-05 11:36:14 -06:00

280 lines
7.6 KiB
Go

package handlers
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/services"
)
// 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, stripeService *services.StripeService) *SubscriptionHandler {
return &SubscriptionHandler{
subscriptionService: subscriptionService,
stripeService: stripeService,
}
}
// GetSubscription handles GET /api/subscription/
func (h *SubscriptionHandler) GetSubscription(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
subscription, err := h.subscriptionService.GetSubscription(user.ID)
if err != nil {
return err
}
return c.JSON(http.StatusOK, subscription)
}
// GetSubscriptionStatus handles GET /api/subscription/status/
func (h *SubscriptionHandler) GetSubscriptionStatus(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
status, err := h.subscriptionService.GetSubscriptionStatus(user.ID)
if err != nil {
return err
}
return c.JSON(http.StatusOK, status)
}
// GetUpgradeTrigger handles GET /api/subscription/upgrade-trigger/:key/
func (h *SubscriptionHandler) GetUpgradeTrigger(c echo.Context) error {
key := c.Param("key")
trigger, err := h.subscriptionService.GetUpgradeTrigger(key)
if err != nil {
return err
}
return c.JSON(http.StatusOK, trigger)
}
// GetAllUpgradeTriggers handles GET /api/subscription/upgrade-triggers/
func (h *SubscriptionHandler) GetAllUpgradeTriggers(c echo.Context) error {
triggers, err := h.subscriptionService.GetAllUpgradeTriggers()
if err != nil {
return err
}
return c.JSON(http.StatusOK, triggers)
}
// GetFeatureBenefits handles GET /api/subscription/features/
func (h *SubscriptionHandler) GetFeatureBenefits(c echo.Context) error {
benefits, err := h.subscriptionService.GetFeatureBenefits()
if err != nil {
return err
}
return c.JSON(http.StatusOK, benefits)
}
// GetPromotions handles GET /api/subscription/promotions/
func (h *SubscriptionHandler) GetPromotions(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
promotions, err := h.subscriptionService.GetActivePromotions(user.ID)
if err != nil {
return err
}
return c.JSON(http.StatusOK, promotions)
}
// ProcessPurchase handles POST /api/subscription/purchase/
func (h *SubscriptionHandler) ProcessPurchase(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req services.ProcessPurchaseRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return err
}
var subscription *services.SubscriptionResponse
switch req.Platform {
case "ios":
// StoreKit 2 uses transaction_id, StoreKit 1 uses receipt_data
if req.TransactionID == "" && req.ReceiptData == "" {
return apperrors.BadRequest("error.receipt_data_required")
}
subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID)
case "android":
if req.PurchaseToken == "" {
return apperrors.BadRequest("error.purchase_token_required")
}
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
default:
return apperrors.BadRequest("error.invalid_platform")
}
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_upgraded",
"subscription": subscription,
})
}
// CancelSubscription handles POST /api/subscription/cancel/
func (h *SubscriptionHandler) CancelSubscription(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
subscription, err := h.subscriptionService.CancelSubscription(user.ID)
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_cancelled",
"subscription": subscription,
})
}
// RestoreSubscription handles POST /api/subscription/restore/
func (h *SubscriptionHandler) RestoreSubscription(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req services.ProcessPurchaseRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return err
}
// Same logic as ProcessPurchase - validates receipt/token and restores
var subscription *services.SubscriptionResponse
switch req.Platform {
case "ios":
subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID)
case "android":
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
default:
return apperrors.BadRequest("error.invalid_platform")
}
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_restored",
"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,
})
}