b67f7f9e6b
Trace data revealed subscription_subscriptionsettings was consuming
1,983s of cumulative DB time per day (180× more than the next-largest
table) for a 32-byte singleton row of admin-toggleable global flags.
Root cause was a 30-second poll loop in monitoring.Service per pod
plus uncached reads on every authed status check / CreateResidence /
Stripe webhook. Fix is layered:
1. Redis cache for SubscriptionSettings — same shape as the
residence-IDs cache. 30-min TTL, explicit invalidation on admin
write. New CacheService.{Cache,GetCached,Invalidate}SubscriptionSettings
plus a cachedSubscriptionSettings helper in services/.
2. SubscriptionService, StripeService, and both admin handlers
(settings + limitations) now read through the cache. Admin write
handlers invalidate so toggles propagate cluster-wide within ms
instead of waiting for the TTL.
3. monitoring.Service.syncSettingsFromDB also reads from Redis first
(raw redis.Client to avoid a services→monitoring import cycle).
Polling interval bumped 30s → 5min. Combined with Redis-shared
cache, cluster-wide DB hits from this poll go from ~480/hour to
~2/hour — a 240× reduction.
4. StripeService.CreateCheckoutSession now takes ctx so the cached
settings span (and the Stripe webhook trace) stay attached to the
request. Handler call site updated.
5. Admin handlers' direct h.db.First calls switched to
db.WithContext(ctx) so the resulting orphan SQL spans nest under
the admin request span in Jaeger.
Net DB query rate for subscription_subscriptionsettings should drop
from 0.101/sec to ~0/sec with occasional invalidation-driven refills,
and the table's cumulative DB time from 1,983s/day to ~10s/day.
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(c.Request().Context(), 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,
|
|
})
|
|
}
|