package api import ( "encoding/json" "errors" "log/slog" "net/http" "strings" "github.com/alchemistkay/guestguard/internal/billing" "github.com/alchemistkay/guestguard/internal/storage" ) type billingHandler struct { logger *slog.Logger stripe *billing.Client users *storage.UserRepo subscriptions *storage.SubscriptionRepo publicBaseURL string } type checkoutSessionRequest struct { Tier string `json:"tier"` } type checkoutSessionResponse struct { URL string `json:"url"` } // POST /billing/checkout-session — returns the Stripe Checkout URL the // frontend redirects the host to. Mints a Stripe customer on first use // and persists it so repeat calls reuse the same customer. func (h *billingHandler) checkoutSession(w http.ResponseWriter, r *http.Request) { if !h.stripeEnabled(w) { return } hostID, ok := hostFromContext(w, r) if !ok { return } var req checkoutSessionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } tier := billing.Tier(strings.ToLower(req.Tier)) if tier != billing.TierPro && tier != billing.TierBusiness { writeError(w, http.StatusBadRequest, "tier must be 'pro' or 'business'") return } price, err := h.stripe.PriceFor(tier) if err != nil { writeError(w, http.StatusServiceUnavailable, "this tier is not configured yet — contact support") return } user, err := h.users.GetByID(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load user") return } existingCustomerID, err := h.subscriptions.FindCustomerID(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load billing record") return } customerID, err := h.stripe.CreateOrGetCustomer(hostID.String(), user.Email, user.Name, existingCustomerID) if err != nil { h.logger.Error("stripe customer", "err", err) writeError(w, http.StatusBadGateway, "stripe customer error") return } if existingCustomerID == "" { // First time — write a placeholder row so the customer id sticks. if _, err := h.subscriptions.Upsert(r.Context(), storage.UpsertParams{ UserID: hostID, StripeCustomerID: customerID, }); err != nil { h.logger.Error("upsert sub placeholder", "err", err) } } base := strings.TrimRight(h.publicBaseURL, "/") url, err := h.stripe.CreateCheckoutSession(billing.CheckoutSessionParams{ CustomerID: customerID, PriceID: price, SuccessURL: base + "/dashboard?billing=success", CancelURL: base + "/dashboard?billing=cancelled", }) if err != nil { h.logger.Error("stripe checkout session", "err", err) writeError(w, http.StatusBadGateway, "stripe checkout error") return } writeJSON(w, http.StatusOK, checkoutSessionResponse{URL: url}) } type portalSessionResponse struct { URL string `json:"url"` } // POST /billing/portal — returns the customer portal URL so the user // can manage their payment method, view invoices, or cancel. 404 when // the user has no Stripe customer yet (they're still on free). func (h *billingHandler) portalSession(w http.ResponseWriter, r *http.Request) { if !h.stripeEnabled(w) { return } hostID, ok := hostFromContext(w, r) if !ok { return } customerID, err := h.subscriptions.FindCustomerID(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load billing record") return } if customerID == "" { writeError(w, http.StatusNotFound, "no billing account yet — subscribe first") return } url, err := h.stripe.CreatePortalSession(customerID, strings.TrimRight(h.publicBaseURL, "/")+"/dashboard") if err != nil { h.logger.Error("stripe portal", "err", err) writeError(w, http.StatusBadGateway, "stripe portal error") return } writeJSON(w, http.StatusOK, portalSessionResponse{URL: url}) } type subscriptionStatusResponse struct { Tier string `json:"tier"` Status string `json:"status"` CurrentPeriodEnd string `json:"current_period_end,omitempty"` CancelAtPeriodEnd bool `json:"cancel_at_period_end"` Limits struct { EventsPerMonth int `json:"events_per_month"` GuestsPerEvent int `json:"guests_per_event"` } `json:"limits"` Usage struct { EventsThisMonth int `json:"events_this_month"` } `json:"usage"` PortalAvailable bool `json:"portal_available"` } // GET /billing/status — returns the host's current tier + limits + // usage. The frontend uses this to render the billing page and the // 402-modal copy ("you used X of Y events this month"). func (h *billingHandler) status(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } tier := billing.TierFree status := "active" var periodEnd string cancelAtPeriodEnd := false portalAvailable := false if h.subscriptions != nil { sub, err := h.subscriptions.GetActiveByUser(r.Context(), hostID) switch { case err == nil: tier = billing.Tier(sub.Tier) status = sub.Status cancelAtPeriodEnd = sub.CancelAtPeriodEnd if sub.CurrentPeriodEnd != nil { periodEnd = sub.CurrentPeriodEnd.Format("2006-01-02T15:04:05Z") } portalAvailable = sub.StripeCustomerID != "" case errors.Is(err, storage.ErrSubscriptionNotFound): // Free tier — leave defaults. default: writeError(w, http.StatusInternalServerError, "failed to load subscription") return } } limits := billing.LimitsFor(tier) events, _ := h.subscriptions.CountEventsInCurrentMonth(r.Context(), hostID) resp := subscriptionStatusResponse{ Tier: string(tier), Status: status, CurrentPeriodEnd: periodEnd, CancelAtPeriodEnd: cancelAtPeriodEnd, PortalAvailable: portalAvailable, } resp.Limits.EventsPerMonth = limits.EventsPerMonth resp.Limits.GuestsPerEvent = limits.GuestsPerEvent resp.Usage.EventsThisMonth = events writeJSON(w, http.StatusOK, resp) } // stripeEnabled returns true if the billing client is configured, else // writes 503 and returns false. The /billing/status path skips this so // the frontend can render a "free tier" page in dev environments. func (h *billingHandler) stripeEnabled(w http.ResponseWriter) bool { if h.stripe == nil || !h.stripe.Enabled() { writeError(w, http.StatusServiceUnavailable, "billing is not configured on this instance") return false } return true }