package api import ( "context" "errors" "net/http" "strings" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/billing" "github.com/alchemistkay/guestguard/internal/storage" ) // tierEnforcer wraps the SubscriptionRepo with policy decisions. Lives // here (not in storage) because the policy is HTTP-shaped: we map the // outcome to 402 + an upgrade URL. type tierEnforcer struct { subs *storage.SubscriptionRepo publicBaseURL string } func newTierEnforcer(subs *storage.SubscriptionRepo, publicBaseURL string) *tierEnforcer { return &tierEnforcer{subs: subs, publicBaseURL: publicBaseURL} } // currentTier returns the host's effective tier. ErrSubscriptionNotFound // means "no granting subscription on file" → free. Other DB errors // bubble up. func (e *tierEnforcer) currentTier(ctx context.Context, hostID uuid.UUID) (billing.Tier, error) { if e == nil || e.subs == nil { return billing.TierFree, nil } sub, err := e.subs.GetActiveByUser(ctx, hostID) if err != nil { if errors.Is(err, storage.ErrSubscriptionNotFound) { return billing.TierFree, nil } return "", err } if !billing.StatusGrantsAccess(sub.Status) { return billing.TierFree, nil } tier := billing.Tier(sub.Tier) if !tier.Valid() { return billing.TierFree, nil } return tier, nil } // allowEventCreate verifies the host's monthly event budget. Returns // true when the request may proceed. On denial it writes a 402 with the // upgrade hint and returns false. func (e *tierEnforcer) allowEventCreate(w http.ResponseWriter, r *http.Request, hostID uuid.UUID) bool { if e == nil || e.subs == nil { return true } tier, err := e.currentTier(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check plan") return false } limit := billing.LimitsFor(tier).EventsPerMonth if limit < 0 { return true } used, err := e.subs.CountEventsInCurrentMonth(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to count events") return false } if used >= limit { e.writePaymentRequired(w, "events_per_month", tier, used, limit, "You've reached your monthly event limit on the "+strings.ToUpper(string(tier))+" plan.") return false } return true } // allowGuestCreate verifies the per-event guest cap. Same shape as // allowEventCreate. func (e *tierEnforcer) allowGuestCreate(w http.ResponseWriter, r *http.Request, hostID, eventID uuid.UUID) bool { if e == nil || e.subs == nil { return true } tier, err := e.currentTier(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check plan") return false } limit := billing.LimitsFor(tier).GuestsPerEvent if limit < 0 { return true } used, err := e.subs.CountGuestsByEvent(r.Context(), eventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to count guests") return false } if used >= limit { e.writePaymentRequired(w, "guests_per_event", tier, used, limit, "This event has reached the guest limit on the "+strings.ToUpper(string(tier))+" plan.") return false } return true } // allowGuestImport is the CSV-import variant: check the cap against // existing + incoming row count up-front, before we start the // transaction. Dedup may shrink the actual insert count later — that's // OK, we just stay on the safe side. func (e *tierEnforcer) allowGuestImport(w http.ResponseWriter, r *http.Request, hostID, eventID uuid.UUID, incoming int) bool { if e == nil || e.subs == nil || incoming == 0 { return true } tier, err := e.currentTier(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check plan") return false } limit := billing.LimitsFor(tier).GuestsPerEvent if limit < 0 { return true } used, err := e.subs.CountGuestsByEvent(r.Context(), eventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to count guests") return false } if used+incoming > limit { e.writePaymentRequired(w, "guests_per_event", tier, used, limit, "This import would exceed the guest limit on the "+strings.ToUpper(string(tier))+" plan.") return false } return true } // allowFeature gates a boolean Tier 2 feature (branding, scanner, // broadcasts) against the host's current plan. On denial it writes a // 402 with an upgrade payload and returns false. The `reason` value // goes back to the frontend so it can render a targeted upgrade modal // instead of a generic "upgrade your plan" prompt. func (e *tierEnforcer) allowFeature(w http.ResponseWriter, r *http.Request, hostID uuid.UUID, reason, friendly string) bool { if e == nil || e.subs == nil { return true } tier, err := e.currentTier(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check plan") return false } limits := billing.LimitsFor(tier) allowed := false switch reason { case "custom_branding": allowed = limits.CustomBranding case "scanner": allowed = limits.Scanner case "broadcasts": allowed = limits.Broadcasts default: // Unknown feature — be conservative, treat as allowed so a // future caller can ship before the gate is hooked up. return true } if allowed { return true } e.writePaymentRequired(w, reason, tier, 0, 0, friendly) return false } // allowCollaboratorInvite gates the per-event collaborator count. Used // by POST /events/{id}/collaborators before we mint the invite — saves // us creating an invitation token only to surface a 402 on accept. func (e *tierEnforcer) allowCollaboratorInvite(w http.ResponseWriter, r *http.Request, hostID, eventID uuid.UUID) bool { if e == nil || e.subs == nil { return true } tier, err := e.currentTier(r.Context(), hostID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to check plan") return false } limit := billing.LimitsFor(tier).MaxCollaborators if limit < 0 { return true } used, err := e.subs.CountCollaboratorsByEvent(r.Context(), eventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to count collaborators") return false } if used >= limit { msg := "You've reached the collaborator limit on the " + strings.ToUpper(string(tier)) + " plan." if tier == billing.TierFree { msg = "Collaborators aren't included on the free plan. Upgrade to share this event with editors or viewers." } e.writePaymentRequired(w, "max_collaborators", tier, used, limit, msg) return false } return true } type paymentRequiredBody struct { Error string `json:"error"` Reason string `json:"reason"` Tier string `json:"tier"` Used int `json:"used"` Limit int `json:"limit"` UpgradeURL string `json:"upgrade_url"` } func (e *tierEnforcer) writePaymentRequired(w http.ResponseWriter, reason string, tier billing.Tier, used, limit int, msg string) { body := paymentRequiredBody{ Error: msg, Reason: reason, Tier: string(tier), Used: used, Limit: limit, UpgradeURL: strings.TrimRight(e.publicBaseURL, "/") + "/dashboard/billing", } writeJSON(w, http.StatusPaymentRequired, body) }