package api import ( "encoding/json" "io" "log/slog" "net/http" "time" "github.com/stripe/stripe-go/v82" "github.com/alchemistkay/guestguard/internal/billing" "github.com/alchemistkay/guestguard/internal/storage" ) // stripeWebhookHandler accepts and verifies Stripe events, then // projects subscription lifecycle changes onto the subscriptions table. // We track only what middleware needs to decide access — tier + status + // period bounds. Invoice events (payment failed / succeeded) are logged // for observability; dunning automation lands in Block F3. type stripeWebhookHandler struct { logger *slog.Logger stripe *billing.Client subs *storage.SubscriptionRepo } // POST /webhooks/stripe — signature-verified Stripe event sink. func (h *stripeWebhookHandler) handle(w http.ResponseWriter, r *http.Request) { if h.stripe == nil || !h.stripe.Enabled() { // Not configured on this instance — reject so a misrouted event // isn't silently swallowed. Stripe will retry which is harmless. w.WriteHeader(http.StatusServiceUnavailable) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { writeError(w, http.StatusBadRequest, "read body") return } defer r.Body.Close() event, err := h.stripe.VerifyWebhook(body, r.Header.Get("Stripe-Signature")) if err != nil { h.logger.Warn("stripe webhook signature failed", "err", err) writeError(w, http.StatusBadRequest, "invalid signature") return } switch event.Type { case "customer.subscription.created", "customer.subscription.updated": h.applySubscription(r, event) case "customer.subscription.deleted": h.applySubscriptionDeleted(r, event) case "invoice.payment_succeeded": // Clear past_due if Stripe says payment caught up. Most flows are // already covered by the subscription.updated event Stripe also // fires — this is belt-and-braces. h.applySubscription(r, event) case "invoice.payment_failed": h.logger.Warn("stripe invoice payment failed", "event_id", event.ID) // Subscription.status will flip to past_due via the // subscription.updated event Stripe fires alongside. default: h.logger.Debug("stripe event ignored", "type", event.Type) } w.WriteHeader(http.StatusOK) } // applySubscription patches the subscriptions row keyed by Stripe // customer id. Best-effort — failures here are logged but don't NACK // the webhook (Stripe would retry forever and the row would never // converge). func (h *stripeWebhookHandler) applySubscription(r *http.Request, event stripe.Event) { var sub stripe.Subscription if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { // Some invoice events carry an Invoice payload — try to extract // the subscription id from there and short-circuit on status. h.logger.Debug("stripe webhook: not a subscription payload", "type", event.Type, "err", err) return } if sub.Customer == nil || sub.Customer.ID == "" { h.logger.Warn("stripe webhook: subscription has no customer", "subscription", sub.ID) return } tier := tierFromSubscription(&sub) status := string(sub.Status) cancelAtPeriodEnd := sub.CancelAtPeriodEnd // As of API 2024-10-28, current_period_end lives on the subscription // item, not the subscription. We pick the earliest item's end — for // single-item subscriptions (our case) that's the canonical one. var periodEnd *time.Time for _, item := range sub.Items.Data { if item.CurrentPeriodEnd > 0 { t := time.Unix(item.CurrentPeriodEnd, 0).UTC() if periodEnd == nil || t.Before(*periodEnd) { periodEnd = &t } } } subID := sub.ID if err := h.subs.UpdateByCustomer(r.Context(), sub.Customer.ID, storage.UpsertParams{ StripeSubscriptionID: &subID, Tier: stringPtr(string(tier)), Status: &status, CurrentPeriodEnd: periodEnd, CancelAtPeriodEnd: &cancelAtPeriodEnd, }); err != nil { h.logger.Error("stripe webhook: update subscription failed", "err", err) } } func (h *stripeWebhookHandler) applySubscriptionDeleted(r *http.Request, event stripe.Event) { var sub stripe.Subscription if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { h.logger.Warn("stripe webhook: bad deleted payload", "err", err) return } if sub.Customer == nil { return } status := "canceled" if err := h.subs.UpdateByCustomer(r.Context(), sub.Customer.ID, storage.UpsertParams{ Status: &status, }); err != nil { h.logger.Error("stripe webhook: mark canceled failed", "err", err) } } // tierFromSubscription inspects the Stripe price metadata to figure out // which GuestGuard tier this subscription corresponds to. We read a // price-level metadata key `gg_tier` (set in the Stripe dashboard when // you create the Price). Fallback: free. func tierFromSubscription(sub *stripe.Subscription) billing.Tier { if sub == nil || len(sub.Items.Data) == 0 { return billing.TierFree } for _, item := range sub.Items.Data { if item.Price == nil { continue } if v, ok := item.Price.Metadata["gg_tier"]; ok { t := billing.Tier(v) if t.Valid() { return t } } // Heuristic fallback for tests / unconfigured prices: look at the // recurring interval and amount tier. if item.Price.Recurring != nil && item.Price.UnitAmount >= 19900 { return billing.TierBusiness } if item.Price.Recurring != nil && item.Price.UnitAmount >= 4900 { return billing.TierPro } } return billing.TierFree } func stringPtr(s string) *string { return &s }