mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +00:00
Tier resolution matched a subscription's price ID against the configured stripe_price_monthly/stripe_price_yearly, which inverts what a price change is supposed to do. Stripe prices are immutable, so changing what a tier costs means creating a new price, and Stripe never migrates existing subscribers off the old one. Updating the config to the new price IDs therefore un-tiers precisely the subscribers a price change is meant to leave alone. They did not even drop cleanly to free. An unresolved tier logs a warning, returns nil, and the event is recorded in stripe_processed_events -- so Stripe answers 200, never redelivers, and a later dashboard Resend is swallowed by the idempotency check. Reproduced against the sandbox: a subscription on a price the config does not list granted nothing, and the event could not be replayed afterwards. A tier has one product and many prices over its life, so the product is the durable key for an entitlement. Tiers gain a stripe_product field, and resolution tries the product first, falling back to the price IDs so configs without it keep working unchanged. Checkout still keys on price -- that direction has to name a specific price to charge. Verified live: a subscription on a price created outside the config, under the Pro product, resolved to tierName=Pro tierRank=2 and landed on the hold. The same shape with an unknown product produced the silent no-op before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB
115 lines
5.2 KiB
Go
115 lines
5.2 KiB
Go
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
|
|
}
|