package services import ( "encoding/json" "fmt" "os" "time" "github.com/rs/zerolog/log" "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/casera-api/internal/apperrors" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-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 } // 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 { key := os.Getenv("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 := os.Getenv("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(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() 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("casera_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. func (s *StripeService) getTrialDays() (int, error) { settings, err := s.subscriptionRepo.GetSettings() if err != nil { return 0, err } if !settings.TrialEnabled { return 0, nil } return settings.TrialDurationDays, nil }