package billing // Config holds appview billing/Stripe configuration. // Parsed from the appview config YAML's billing section. type Config struct { // Stripe secret key (sk_test_... or sk_live_...). // Can also be set via STRIPE_SECRET_KEY env var (takes precedence over config). // Billing is enabled automatically when this key is set (requires -tags billing build). StripeSecretKey string `yaml:"stripe_secret_key" comment:"Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set."` // Stripe webhook signing secret (whsec_...). // Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence over config). WebhookSecret string `yaml:"webhook_secret" comment:"Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence)."` // Currency code for Stripe checkout (e.g. "usd"). Currency string `yaml:"currency" comment:"ISO 4217 currency code (e.g. \"usd\")."` // URL to redirect after successful checkout. {base_url} is replaced at runtime. SuccessURL string `yaml:"success_url" comment:"Redirect URL after successful checkout. Use {base_url} placeholder."` // URL to redirect after cancelled checkout. {base_url} is replaced at runtime. CancelURL string `yaml:"cancel_url" comment:"Redirect URL after cancelled checkout. Use {base_url} placeholder."` // Subscription tiers with Stripe price IDs. Tiers []BillingTierConfig `yaml:"tiers" comment:"Subscription tiers ordered by rank (lowest to highest)."` } // BillingTierConfig represents a single tier with optional Stripe pricing. type BillingTierConfig struct { // Tier name (matches hold quota tier names for rank mapping). Name string `yaml:"name" comment:"Tier name. Position in list determines rank (0-based)."` // Short description shown on the plan card. Description string `yaml:"description,omitempty" comment:"Short description shown on the plan card."` // List of features included in this tier (rendered as bullet points). Features []string `yaml:"features,omitempty" comment:"List of features included in this tier."` // Stripe price ID for monthly billing. Empty = free tier. StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty" comment:"Stripe price ID for monthly billing. Empty = free tier."` // Stripe price ID for yearly billing. StripePriceYearly string `yaml:"stripe_price_yearly,omitempty" comment:"Stripe price ID for yearly billing."` // Stripe product ID backing this tier. Entitlements resolve on this, not // on the price IDs above, so a price change does not strand subscribers. StripeProduct string `yaml:"stripe_product,omitempty" comment:"Stripe product ID for this tier. Entitlements resolve on this, so changing a price does not remove features from existing subscribers. Falls back to the price IDs when empty."` // Maximum number of webhooks for this tier (-1 = unlimited). MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhooks for this tier (-1 = unlimited)."` // Whether all webhook trigger types are available (not just first-scan). WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."` // Whether AI Image Advisor is available for this tier. AIAdvisor bool `yaml:"ai_advisor" comment:"Enable AI Image Advisor for this tier."` // Whether this tier earns a supporter badge on user profiles. SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."` } // GetTierByProductID finds the tier backed by the given Stripe product ID. // Returns the tier name and rank, or empty string and -1 if not found. // // Product rather than price is the durable key for an entitlement. Stripe // prices are immutable, so changing what a tier costs means creating a new // price under the same product, and existing subscribers keep billing on the // old one indefinitely — Stripe never migrates them. Resolving entitlements by // price ID therefore un-tiers precisely the subscribers a price change is // meant to leave alone. Resolving by product grandfathers them for free. // // Prices stay the key in the other direction: checkout has to name a specific // price to charge. func (c *Config) GetTierByProductID(productID string) (string, int) { if c == nil || productID == "" { return "", -1 } for i, tier := range c.Tiers { if tier.StripeProduct == productID { return tier.Name, i } } return "", -1 } // GetTierByPriceID finds the tier that contains the given Stripe price ID. // Returns the tier name and rank, or empty string and -1 if not found. // // Kept as the fallback for tiers with no stripe_product configured, so an // existing config keeps resolving unchanged. func (c *Config) GetTierByPriceID(priceID string) (string, int) { if c == nil || priceID == "" { return "", -1 } for i, tier := range c.Tiers { if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID { return tier.Name, i } } return "", -1 } // TierRank returns the 0-based rank of a tier by name, or -1 if not found. func (c *Config) TierRank(name string) int { if c == nil { return -1 } for i, tier := range c.Tiers { if tier.Name == name { return i } } return -1 }