// Package billing models GuestGuard's subscription tiers and Stripe // integration. Plan limits live here so handler + middleware layers don't // hard-code numbers — change a value, restart the API, and the cap moves. package billing import "fmt" // Tier is the user's current subscription tier. Stored as text in the // subscriptions table. type Tier string const ( TierFree Tier = "free" TierPro Tier = "pro" TierBusiness Tier = "business" ) func (t Tier) Valid() bool { switch t { case TierFree, TierPro, TierBusiness: return true } return false } // Limits enforces what a tier may do. -1 means unlimited. type Limits struct { EventsPerMonth int GuestsPerEvent int // Tier 2 feature gates. Auto-reminders, fraud detection, and // analytics are intentionally on every tier — those are the // safety + visibility features paying customers expect to find // out of the box. The gates below are the genuine upsell levers. MaxCollaborators int // shared editors/viewers per event (-1 = unlimited) CustomBranding bool // logo / cover / colour overrides Scanner bool // day-of check-in scanner + magic link Broadcasts bool // custom broadcasts (auto-reminders are always on) } // TierLimits is the canonical plan-limits table. Matches docs/TIER1_PLAN.md // Block F pricing (placeholder until market validation). var TierLimits = map[Tier]Limits{ TierFree: { EventsPerMonth: 1, GuestsPerEvent: 50, MaxCollaborators: 0, CustomBranding: false, Scanner: false, Broadcasts: false, }, TierPro: { EventsPerMonth: 10, GuestsPerEvent: 1000, MaxCollaborators: 5, CustomBranding: true, Scanner: true, Broadcasts: true, }, TierBusiness: { EventsPerMonth: -1, GuestsPerEvent: 5000, MaxCollaborators: -1, CustomBranding: true, Scanner: true, Broadcasts: true, }, } // LimitsFor returns the limits for a tier, defaulting to Free for unknown // strings — defensive, so a typo or future-tier in the DB never grants // unlimited access. func LimitsFor(t Tier) Limits { if l, ok := TierLimits[t]; ok { return l } return TierLimits[TierFree] } // StatusGrantsAccess returns true if a subscription with this status // should be treated as paid. Mirrors the unique-index predicate in // 0005_billing.up.sql. func StatusGrantsAccess(status string) bool { switch status { case "active", "past_due", "trialing": return true } return false } // LimitError describes a denied-by-policy outcome. The handler layer // turns this into a 402 with a JSON body the frontend uses to render // the upgrade modal. type LimitError struct { Reason string Tier Tier Limit int Used int } func (e *LimitError) Error() string { return fmt.Sprintf("billing: %s (tier=%s used=%d limit=%d)", e.Reason, e.Tier, e.Used, e.Limit) }