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>
467 lines
16 KiB
Go
467 lines
16 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/spf13/viper"
|
|
"github.com/stripe/stripe-go/v81"
|
|
portalsession "github.com/stripe/stripe-go/v81/billingportal/session"
|
|
checkoutsession "github.com/stripe/stripe-go/v81/checkout/session"
|
|
"github.com/stripe/stripe-go/v81/customer"
|
|
"github.com/stripe/stripe-go/v81/webhook"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
)
|
|
|
|
// StripeService handles Stripe checkout, portal, and webhook processing
|
|
// for web-based subscription purchases.
|
|
type StripeService struct {
|
|
subscriptionRepo *repositories.SubscriptionRepository
|
|
userRepo *repositories.UserRepository
|
|
webhookSecret string
|
|
cache *CacheService
|
|
}
|
|
|
|
// SetCacheService wires Redis caching for SubscriptionSettings reads.
|
|
func (s *StripeService) SetCacheService(cache *CacheService) {
|
|
s.cache = cache
|
|
}
|
|
|
|
// NewStripeService creates a new Stripe service. It initializes the global
|
|
// Stripe API key from the STRIPE_SECRET_KEY environment variable. If the key
|
|
// is not set, a warning is logged but the service is still returned (matching
|
|
// the pattern used by the Apple/Google IAP clients).
|
|
func NewStripeService(
|
|
subscriptionRepo *repositories.SubscriptionRepository,
|
|
userRepo *repositories.UserRepository,
|
|
) *StripeService {
|
|
// S-21: Use Viper config instead of os.Getenv for consistent configuration management
|
|
key := viper.GetString("STRIPE_SECRET_KEY")
|
|
if key == "" {
|
|
log.Warn().Msg("STRIPE_SECRET_KEY not set, Stripe integration will not work")
|
|
} else {
|
|
stripe.Key = key
|
|
log.Info().Msg("Stripe API key configured")
|
|
}
|
|
|
|
webhookSecret := viper.GetString("STRIPE_WEBHOOK_SECRET")
|
|
if webhookSecret == "" {
|
|
log.Warn().Msg("STRIPE_WEBHOOK_SECRET not set, webhook verification will fail")
|
|
}
|
|
|
|
return &StripeService{
|
|
subscriptionRepo: subscriptionRepo,
|
|
userRepo: userRepo,
|
|
webhookSecret: webhookSecret,
|
|
}
|
|
}
|
|
|
|
// CreateCheckoutSession creates a Stripe Checkout Session for a web subscription purchase.
|
|
// It ensures the user has a Stripe customer record and configures the session with a trial
|
|
// period if the user has not used their trial yet.
|
|
func (s *StripeService) CreateCheckoutSession(ctx context.Context, userID uint, priceID string, successURL string, cancelURL string) (string, error) {
|
|
// Get or create the user's subscription record
|
|
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
|
if err != nil {
|
|
return "", apperrors.Internal(err)
|
|
}
|
|
|
|
// Get the user's email for the Stripe customer
|
|
user, err := s.userRepo.FindByID(userID)
|
|
if err != nil {
|
|
return "", apperrors.Internal(err)
|
|
}
|
|
|
|
// Get or create a Stripe customer
|
|
stripeCustomerID, err := s.getOrCreateStripeCustomer(sub, user)
|
|
if err != nil {
|
|
return "", apperrors.Internal(err)
|
|
}
|
|
|
|
// Build the checkout session parameters
|
|
params := &stripe.CheckoutSessionParams{
|
|
Customer: stripe.String(stripeCustomerID),
|
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
|
SuccessURL: stripe.String(successURL),
|
|
CancelURL: stripe.String(cancelURL),
|
|
ClientReferenceID: stripe.String(fmt.Sprintf("%d", userID)),
|
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
|
{
|
|
Price: stripe.String(priceID),
|
|
Quantity: stripe.Int64(1),
|
|
},
|
|
},
|
|
}
|
|
|
|
// Offer a trial period if the user has not used their trial yet
|
|
if !sub.TrialUsed {
|
|
trialDays, err := s.getTrialDays(ctx)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to get trial duration from settings, skipping trial")
|
|
} else if trialDays > 0 {
|
|
params.SubscriptionData = &stripe.CheckoutSessionSubscriptionDataParams{
|
|
TrialPeriodDays: stripe.Int64(int64(trialDays)),
|
|
}
|
|
}
|
|
}
|
|
|
|
session, err := checkoutsession.New(params)
|
|
if err != nil {
|
|
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to create Stripe checkout session")
|
|
return "", apperrors.Internal(err)
|
|
}
|
|
|
|
log.Info().
|
|
Uint("user_id", userID).
|
|
Str("session_id", session.ID).
|
|
Str("price_id", priceID).
|
|
Msg("Stripe checkout session created")
|
|
|
|
return session.URL, nil
|
|
}
|
|
|
|
// CreatePortalSession creates a Stripe Customer Portal session so the user
|
|
// can manage their subscription (cancel, change plan, update payment method).
|
|
func (s *StripeService) CreatePortalSession(userID uint, returnURL string) (string, error) {
|
|
sub, err := s.subscriptionRepo.FindByUserID(userID)
|
|
if err != nil {
|
|
return "", apperrors.NotFound("error.subscription_not_found")
|
|
}
|
|
|
|
if sub.StripeCustomerID == nil || *sub.StripeCustomerID == "" {
|
|
return "", apperrors.BadRequest("error.no_stripe_customer")
|
|
}
|
|
|
|
params := &stripe.BillingPortalSessionParams{
|
|
Customer: sub.StripeCustomerID,
|
|
ReturnURL: stripe.String(returnURL),
|
|
}
|
|
|
|
session, err := portalsession.New(params)
|
|
if err != nil {
|
|
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to create Stripe portal session")
|
|
return "", apperrors.Internal(err)
|
|
}
|
|
|
|
return session.URL, nil
|
|
}
|
|
|
|
// HandleWebhookEvent verifies and processes a Stripe webhook event.
|
|
// It handles checkout completion, subscription lifecycle changes, and invoice events.
|
|
func (s *StripeService) HandleWebhookEvent(payload []byte, signature string) error {
|
|
event, err := webhook.ConstructEvent(payload, signature, s.webhookSecret)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Stripe webhook signature verification failed")
|
|
return apperrors.BadRequest("error.invalid_webhook_signature")
|
|
}
|
|
|
|
log.Info().
|
|
Str("event_type", string(event.Type)).
|
|
Str("event_id", event.ID).
|
|
Msg("Processing Stripe webhook event")
|
|
|
|
switch event.Type {
|
|
case "checkout.session.completed":
|
|
return s.handleCheckoutCompleted(event)
|
|
case "customer.subscription.updated":
|
|
return s.handleSubscriptionUpdated(event)
|
|
case "customer.subscription.deleted":
|
|
return s.handleSubscriptionDeleted(event)
|
|
case "invoice.paid":
|
|
return s.handleInvoicePaid(event)
|
|
case "invoice.payment_failed":
|
|
return s.handleInvoicePaymentFailed(event)
|
|
default:
|
|
log.Debug().Str("event_type", string(event.Type)).Msg("Unhandled Stripe webhook event type")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// handleCheckoutCompleted processes a successful checkout session. It links the Stripe
|
|
// customer and subscription to the user's record and upgrades them to Pro.
|
|
func (s *StripeService) handleCheckoutCompleted(event stripe.Event) error {
|
|
var session stripe.CheckoutSession
|
|
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
|
|
log.Error().Err(err).Msg("Failed to unmarshal checkout session from webhook")
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Extract the user ID from client_reference_id
|
|
var userID uint
|
|
if _, err := fmt.Sscanf(session.ClientReferenceID, "%d", &userID); err != nil {
|
|
log.Error().Str("client_reference_id", session.ClientReferenceID).Msg("Invalid client_reference_id in checkout session")
|
|
return apperrors.BadRequest("error.invalid_client_reference_id")
|
|
}
|
|
|
|
// Get or create the subscription record
|
|
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
|
if err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Save Stripe customer and subscription IDs
|
|
if session.Customer != nil {
|
|
sub.StripeCustomerID = &session.Customer.ID
|
|
}
|
|
if session.Subscription != nil {
|
|
sub.StripeSubscriptionID = &session.Subscription.ID
|
|
}
|
|
|
|
if err := s.subscriptionRepo.Update(sub); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Upgrade to Pro. Use a far-future expiry for now; the invoice.paid event
|
|
// will set the real period_end once the first invoice is finalized.
|
|
expiresAt := time.Now().UTC().AddDate(1, 0, 0)
|
|
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, models.PlatformStripe); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
customerID := ""
|
|
if session.Customer != nil {
|
|
customerID = session.Customer.ID
|
|
}
|
|
subscriptionID := ""
|
|
if session.Subscription != nil {
|
|
subscriptionID = session.Subscription.ID
|
|
}
|
|
log.Info().
|
|
Uint("user_id", userID).
|
|
Str("stripe_customer_id", customerID).
|
|
Str("stripe_subscription_id", subscriptionID).
|
|
Msg("Checkout completed, user upgraded to Pro")
|
|
|
|
// TODO: Send push notification to user's devices when subscription activates
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleSubscriptionUpdated processes subscription status changes. It upgrades or
|
|
// downgrades the user depending on the subscription's current status.
|
|
func (s *StripeService) handleSubscriptionUpdated(event stripe.Event) error {
|
|
var subscription stripe.Subscription
|
|
if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil {
|
|
log.Error().Err(err).Msg("Failed to unmarshal subscription from webhook")
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
sub, err := s.findSubscriptionByStripeID(subscription.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch subscription.Status {
|
|
case stripe.SubscriptionStatusActive, stripe.SubscriptionStatusTrialing:
|
|
// Subscription is healthy, ensure user is Pro
|
|
expiresAt := time.Unix(subscription.CurrentPeriodEnd, 0).UTC()
|
|
if err := s.subscriptionRepo.UpgradeToPro(sub.UserID, expiresAt, models.PlatformStripe); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
log.Info().Uint("user_id", sub.UserID).Str("status", string(subscription.Status)).Msg("Stripe subscription active")
|
|
|
|
case stripe.SubscriptionStatusPastDue:
|
|
log.Warn().Uint("user_id", sub.UserID).Msg("Stripe subscription past due, waiting for retry")
|
|
// Don't downgrade yet; Stripe will retry the payment automatically.
|
|
|
|
case stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusUnpaid:
|
|
// Check if the user has active subscriptions from other sources before downgrading
|
|
if s.isActiveFromOtherSources(sub) {
|
|
log.Info().
|
|
Uint("user_id", sub.UserID).
|
|
Str("status", string(subscription.Status)).
|
|
Msg("Stripe subscription ended but user has other active sources, keeping Pro")
|
|
return nil
|
|
}
|
|
if err := s.subscriptionRepo.DowngradeToFree(sub.UserID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
log.Info().Uint("user_id", sub.UserID).Str("status", string(subscription.Status)).Msg("User downgraded to Free after Stripe subscription ended")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleSubscriptionDeleted processes a subscription that has been fully cancelled
|
|
// and is no longer active. It downgrades the user unless they have active subscriptions
|
|
// from other sources (Apple, Google).
|
|
func (s *StripeService) handleSubscriptionDeleted(event stripe.Event) error {
|
|
var subscription stripe.Subscription
|
|
if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil {
|
|
log.Error().Err(err).Msg("Failed to unmarshal subscription from webhook")
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
sub, err := s.findSubscriptionByStripeID(subscription.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check multi-source before downgrading
|
|
if s.isActiveFromOtherSources(sub) {
|
|
log.Info().
|
|
Uint("user_id", sub.UserID).
|
|
Msg("Stripe subscription deleted but user has other active sources, keeping Pro")
|
|
return nil
|
|
}
|
|
|
|
if err := s.subscriptionRepo.DowngradeToFree(sub.UserID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
log.Info().Uint("user_id", sub.UserID).Msg("User downgraded to Free after Stripe subscription deleted")
|
|
|
|
// TODO: Send push notification to user's devices about subscription ending
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleInvoicePaid processes a successful invoice payment. It updates the subscription
|
|
// expiry to the current billing period's end date and ensures the user is on Pro.
|
|
func (s *StripeService) handleInvoicePaid(event stripe.Event) error {
|
|
var invoice stripe.Invoice
|
|
if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil {
|
|
log.Error().Err(err).Msg("Failed to unmarshal invoice from webhook")
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Only process subscription invoices
|
|
if invoice.Subscription == nil {
|
|
return nil
|
|
}
|
|
|
|
sub, err := s.findSubscriptionByStripeID(invoice.Subscription.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update expiry from the invoice's period end
|
|
expiresAt := time.Unix(invoice.PeriodEnd, 0).UTC()
|
|
if err := s.subscriptionRepo.UpgradeToPro(sub.UserID, expiresAt, models.PlatformStripe); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
log.Info().
|
|
Uint("user_id", sub.UserID).
|
|
Time("expires_at", expiresAt).
|
|
Msg("Invoice paid, subscription renewed")
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleInvoicePaymentFailed logs a warning when a payment fails. We do not downgrade
|
|
// the user here because Stripe will automatically retry the payment according to its
|
|
// Smart Retries schedule.
|
|
func (s *StripeService) handleInvoicePaymentFailed(event stripe.Event) error {
|
|
var invoice stripe.Invoice
|
|
if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil {
|
|
log.Error().Err(err).Msg("Failed to unmarshal invoice from webhook")
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
if invoice.Subscription == nil {
|
|
return nil
|
|
}
|
|
|
|
sub, err := s.findSubscriptionByStripeID(invoice.Subscription.ID)
|
|
if err != nil {
|
|
// If we can't find the subscription, just log and return
|
|
log.Warn().Str("stripe_subscription_id", invoice.Subscription.ID).Msg("Invoice payment failed for unknown subscription")
|
|
return nil
|
|
}
|
|
|
|
log.Warn().
|
|
Uint("user_id", sub.UserID).
|
|
Str("invoice_id", invoice.ID).
|
|
Msg("Stripe invoice payment failed, Stripe will retry automatically")
|
|
|
|
return nil
|
|
}
|
|
|
|
// isActiveFromOtherSources checks if the user has active subscriptions from Apple or Google
|
|
// that should prevent a downgrade when the Stripe subscription ends.
|
|
func (s *StripeService) isActiveFromOtherSources(sub *models.UserSubscription) bool {
|
|
now := time.Now().UTC()
|
|
|
|
// Check Apple subscription
|
|
if sub.HasAppleSubscription() && sub.Tier == models.TierPro && sub.ExpiresAt != nil && now.Before(*sub.ExpiresAt) && sub.Platform != models.PlatformStripe {
|
|
return true
|
|
}
|
|
|
|
// Check Google subscription
|
|
if sub.HasGoogleSubscription() && sub.Tier == models.TierPro && sub.ExpiresAt != nil && now.Before(*sub.ExpiresAt) && sub.Platform != models.PlatformStripe {
|
|
return true
|
|
}
|
|
|
|
// Check active trial
|
|
if sub.IsTrialActive() {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getOrCreateStripeCustomer returns the existing Stripe customer ID from the subscription
|
|
// record, or creates a new Stripe customer and persists the ID.
|
|
func (s *StripeService) getOrCreateStripeCustomer(sub *models.UserSubscription, user *models.User) (string, error) {
|
|
// If we already have a Stripe customer, return it
|
|
if sub.StripeCustomerID != nil && *sub.StripeCustomerID != "" {
|
|
return *sub.StripeCustomerID, nil
|
|
}
|
|
|
|
// Create a new Stripe customer
|
|
params := &stripe.CustomerParams{
|
|
Email: stripe.String(user.Email),
|
|
Name: stripe.String(user.GetFullName()),
|
|
}
|
|
params.AddMetadata("honeydue_user_id", fmt.Sprintf("%d", user.ID))
|
|
|
|
c, err := customer.New(params)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create Stripe customer: %w", err)
|
|
}
|
|
|
|
// Save the customer ID to the subscription record
|
|
sub.StripeCustomerID = &c.ID
|
|
if err := s.subscriptionRepo.Update(sub); err != nil {
|
|
return "", fmt.Errorf("failed to save Stripe customer ID: %w", err)
|
|
}
|
|
|
|
log.Info().
|
|
Uint("user_id", user.ID).
|
|
Str("stripe_customer_id", c.ID).
|
|
Msg("Created new Stripe customer")
|
|
|
|
return c.ID, nil
|
|
}
|
|
|
|
// findSubscriptionByStripeID looks up a UserSubscription by its Stripe subscription ID.
|
|
func (s *StripeService) findSubscriptionByStripeID(stripeSubID string) (*models.UserSubscription, error) {
|
|
sub, err := s.subscriptionRepo.FindByStripeSubscriptionID(stripeSubID)
|
|
if err != nil {
|
|
log.Warn().Str("stripe_subscription_id", stripeSubID).Err(err).Msg("Subscription not found for Stripe ID")
|
|
return nil, apperrors.NotFound("error.subscription_not_found")
|
|
}
|
|
return sub, nil
|
|
}
|
|
|
|
// getTrialDays reads the trial duration from SubscriptionSettings via the
|
|
// shared cache. ctx threads through so the SQL span (on cache miss) attaches
|
|
// to the parent webhook trace.
|
|
func (s *StripeService) getTrialDays(ctx context.Context) (int, error) {
|
|
settings, err := cachedSubscriptionSettings(ctx, s.cache, s.subscriptionRepo)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !settings.TrialEnabled {
|
|
return 0, nil
|
|
}
|
|
return settings.TrialDurationDays, nil
|
|
}
|