Security: - Replace all binding: tags with validate: + c.Validate() in admin handlers - Add rate limiting to auth endpoints (login, register, password reset) - Add security headers (HSTS, XSS protection, nosniff, frame options) - Wire Google Pub/Sub token verification into webhook handler - Replace ParseUnverified with proper OIDC/JWKS key verification - Verify inner Apple JWS signatures in webhook handler - Add io.LimitReader (1MB) to all webhook body reads - Add ownership verification to file deletion - Move hardcoded admin credentials to env vars - Add uniqueIndex to User.Email - Hide ConfirmationCode from JSON serialization - Mask confirmation codes in admin responses - Use http.DetectContentType for upload validation - Fix path traversal in storage service - Replace os.Getenv with Viper in stripe service - Sanitize Redis URLs before logging - Separate DEBUG_FIXED_CODES from DEBUG flag - Reject weak SECRET_KEY in production - Add host check on /_next/* proxy routes - Use explicit localhost CORS origins in debug mode - Replace err.Error() with generic messages in all admin error responses Critical fixes: - Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth - Fix user_customuser -> auth_user table names in raw SQL - Fix dashboard verified query to use UserProfile model - Add escapeLikeWildcards() to prevent SQL wildcard injection Bug fixes: - Add bounds checks for days/expiring_soon query params (1-3650) - Add receipt_data/transaction_id empty-check to RestoreSubscription - Change Active bool -> *bool in device handler - Check all unchecked GORM/FindByIDWithProfile errors - Add validation for notification hour fields (0-23) - Add max=10000 validation on task description updates Transactions & data integrity: - Wrap registration flow in transaction - Wrap QuickComplete in transaction - Move image creation inside completion transaction - Wrap SetSpecialties in transaction - Wrap GetOrCreateToken in transaction - Wrap completion+image deletion in transaction Performance: - Batch completion summaries (2 queries vs 2N) - Reuse single http.Client in IAP validation - Cache dashboard counts (30s TTL) - Batch COUNT queries in admin user list - Add Limit(500) to document queries - Add reminder_stage+due_date filters to reminder queries - Parse AllowedTypes once at init - In-memory user cache in auth middleware (30s TTL) - Timezone change detection cache - Optimize P95 with per-endpoint sorted buffers - Replace crypto/md5 with hash/fnv for ETags Code quality: - Add sync.Once to all monitoring Stop()/Close() methods - Replace 8 fmt.Printf with zerolog in auth service - Log previously discarded errors - Standardize delete response shapes - Route hardcoded English through i18n - Remove FileURL from DocumentResponse (keep MediaURL only) - Thread user timezone through kanban board responses - Initialize empty slices to prevent null JSON - Extract shared field map for task Update/UpdateTx - Delete unused SoftDeleteModel, min(), formatCron, legacy handlers Worker & jobs: - Wire Asynq email infrastructure into worker - Register HandleReminderLogCleanup with daily 3AM cron - Use per-user timezone in HandleSmartReminder - Replace direct DB queries with repository calls - Delete legacy reminder handlers (~200 lines) - Delete unused task type constants Dependencies: - Replace archived jung-kurt/gofpdf with go-pdf/fpdf - Replace unmaintained gomail.v2 with wneessen/go-mail - Add TODO for Echo jwt v3 transitive dep removal Test infrastructure: - Fix MakeRequest/SeedLookupData error handling - Replace os.Exit(0) with t.Skip() in scope/consistency tests - Add 11 new FCM v1 tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
458 lines
15 KiB
Go
458 lines
15 KiB
Go
package services
|
|
|
|
import (
|
|
"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
|
|
}
|
|
|
|
// 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(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("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.
|
|
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
|
|
}
|