e881d37de0
Every public method on these five services now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With TaskService and ResidenceService already migrated, this means every in-process service that touches Postgres now produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span. Endpoints now fully traced (HTTP → service → SQL): - /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification - /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile - /api/contractors/* (CRUD + favorite + by-residence + tasks) - /api/documents/* (CRUD + activate/deactivate + image upload/delete) - /api/notifications/* (list, count, mark-read, prefs, devices) - /api/subscription/* (status, purchase, cancel, triggers, promotions) - All previously-migrated /api/tasks/* and /api/residences/* paths Internal helpers also threaded: - TaskService.sendTaskCompletedNotification → forwards ctx - TaskService.UpdateUserTimezone → forwards ctx to NotificationService - ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit - NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx ~75 method signatures, ~120 handler/test call sites updated. Tests pass green; the only failure is the pre-existing flaky TaskHandler_QuickComplete SQLite race that fails ~60% of runs on master. Step 3 of the observability plan is now genuinely complete: every API endpoint backed by a Go service emits a per-request flame graph with HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
288 lines
8.3 KiB
Go
288 lines
8.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/i18n"
|
|
"github.com/treytartt/honeydue-api/internal/middleware"
|
|
"github.com/treytartt/honeydue-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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context())
|
|
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(c.Request().Context())
|
|
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(c.Request().Context(), 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(c.Request().Context(), user.ID, req.ReceiptData, req.TransactionID)
|
|
case "android":
|
|
if req.PurchaseToken == "" {
|
|
return apperrors.BadRequest("error.purchase_token_required")
|
|
}
|
|
subscription, err = h.subscriptionService.ProcessGooglePurchase(c.Request().Context(), 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": i18n.LocalizedMessage(c, "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(c.Request().Context(), user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"message": i18n.LocalizedMessage(c, "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":
|
|
// B-14: Validate that at least one of receipt_data or transaction_id is provided
|
|
if req.ReceiptData == "" && req.TransactionID == "" {
|
|
return apperrors.BadRequest("error.receipt_data_required")
|
|
}
|
|
subscription, err = h.subscriptionService.ProcessApplePurchase(c.Request().Context(), user.ID, req.ReceiptData, req.TransactionID)
|
|
case "android":
|
|
if req.PurchaseToken == "" {
|
|
return apperrors.BadRequest("error.purchase_token_required")
|
|
}
|
|
subscription, err = h.subscriptionService.ProcessGooglePurchase(c.Request().Context(), 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": i18n.LocalizedMessage(c, "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(c.Request().Context(), 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,
|
|
})
|
|
}
|