mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +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
1157 lines
35 KiB
Go
1157 lines
35 KiB
Go
//go:build billing
|
|
|
|
package billing
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/holdclient"
|
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
|
|
|
"github.com/stripe/stripe-go/v84"
|
|
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
|
|
"github.com/stripe/stripe-go/v84/checkout/session"
|
|
"github.com/stripe/stripe-go/v84/customer"
|
|
"github.com/stripe/stripe-go/v84/price"
|
|
"github.com/stripe/stripe-go/v84/subscription"
|
|
"github.com/stripe/stripe-go/v84/webhook"
|
|
)
|
|
|
|
// Manager handles Stripe billing and pushes tier updates to managed holds.
|
|
type Manager struct {
|
|
cfg *Config
|
|
privateKey *atcrypto.PrivateKeyP256
|
|
appviewDID string
|
|
managedHolds []string
|
|
baseURL string
|
|
stripeKey string
|
|
webhookSecret string
|
|
|
|
// db is the writable appview database, used for Stripe webhook idempotency.
|
|
db *sql.DB
|
|
|
|
// Captain checker: bypasses billing for hold owners
|
|
captainChecker CaptainChecker
|
|
|
|
// Active-hold checker: gates paid features on the user being on a managed hold
|
|
activeHoldChecker ActiveHoldChecker
|
|
|
|
// Customer cache: DID → Stripe customer
|
|
customerCache map[string]*cachedCustomer
|
|
customerCacheMu sync.RWMutex
|
|
|
|
// Price cache: Stripe price ID → unit amount in cents
|
|
priceCache map[string]*cachedPrice
|
|
priceCacheMu sync.RWMutex
|
|
|
|
// Hold tier cache: holdDID → tier list
|
|
holdTierCache map[string]*cachedHoldTiers
|
|
holdTierCacheMu sync.RWMutex
|
|
}
|
|
|
|
type cachedHoldTiers struct {
|
|
tiers []holdclient.HoldTierInfo
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type cachedCustomer struct {
|
|
customer *stripe.Customer
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type cachedPrice struct {
|
|
unitAmount int64
|
|
expiresAt time.Time
|
|
}
|
|
|
|
const customerCacheTTL = 10 * time.Minute
|
|
const priceCacheTTL = 1 * time.Hour
|
|
|
|
// New creates a new billing manager with Stripe integration.
|
|
// Env vars STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET take precedence over config values.
|
|
// database is the writable appview DB, used for Stripe webhook idempotency.
|
|
func New(cfg *Config, privateKey *atcrypto.PrivateKeyP256, appviewDID string, managedHolds []string, baseURL string, database *sql.DB) *Manager {
|
|
stripeKey := os.Getenv("STRIPE_SECRET_KEY")
|
|
if stripeKey == "" {
|
|
stripeKey = cfg.StripeSecretKey
|
|
}
|
|
if stripeKey != "" {
|
|
stripe.Key = stripeKey
|
|
}
|
|
|
|
webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
|
|
if webhookSecret == "" {
|
|
webhookSecret = cfg.WebhookSecret
|
|
}
|
|
|
|
return &Manager{
|
|
cfg: cfg,
|
|
privateKey: privateKey,
|
|
appviewDID: appviewDID,
|
|
managedHolds: managedHolds,
|
|
baseURL: baseURL,
|
|
stripeKey: stripeKey,
|
|
webhookSecret: webhookSecret,
|
|
db: database,
|
|
customerCache: make(map[string]*cachedCustomer),
|
|
priceCache: make(map[string]*cachedPrice),
|
|
holdTierCache: make(map[string]*cachedHoldTiers),
|
|
}
|
|
}
|
|
|
|
// SetCaptainChecker sets a callback that checks if a user is a hold captain.
|
|
// Captains bypass all billing feature gates.
|
|
func (m *Manager) SetCaptainChecker(fn CaptainChecker) {
|
|
m.captainChecker = fn
|
|
}
|
|
|
|
func (m *Manager) isCaptain(userDID string) bool {
|
|
return m.captainChecker != nil && userDID != "" && m.captainChecker(userDID)
|
|
}
|
|
|
|
// SetActiveHoldChecker sets a callback that reports whether a user's active
|
|
// (default) hold is a managed hold. Paid features are gated on this.
|
|
func (m *Manager) SetActiveHoldChecker(fn ActiveHoldChecker) {
|
|
m.activeHoldChecker = fn
|
|
}
|
|
|
|
// onManagedHold reports whether the user's active hold qualifies for paid
|
|
// features. With no checker wired (e.g. tests), it defaults to true to stay
|
|
// backward-compatible.
|
|
func (m *Manager) onManagedHold(userDID string) bool {
|
|
if m.activeHoldChecker == nil {
|
|
return true
|
|
}
|
|
return m.activeHoldChecker(userDID)
|
|
}
|
|
|
|
// WebhookConfigured reports whether a Stripe webhook signing secret is set.
|
|
// Used at startup to fail closed: an empty secret makes webhooks forgeable.
|
|
func (m *Manager) WebhookConfigured() bool {
|
|
return m.webhookSecret != ""
|
|
}
|
|
|
|
// Enabled returns true if billing is properly configured.
|
|
func (m *Manager) Enabled() bool {
|
|
return m.cfg != nil && m.stripeKey != "" && len(m.cfg.Tiers) > 0
|
|
}
|
|
|
|
// GetWebhookLimits returns webhook limits for a user based on their subscription tier.
|
|
// Returns (maxWebhooks, allTriggers). Defaults to the lowest tier's limits.
|
|
// Hold captains get unlimited webhooks with all triggers.
|
|
func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
|
|
if m.isCaptain(userDID) {
|
|
return -1, true // unlimited
|
|
}
|
|
if !m.Enabled() {
|
|
return 1, false
|
|
}
|
|
// Paid features require an active managed hold.
|
|
if !m.onManagedHold(userDID) {
|
|
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
|
|
}
|
|
|
|
info, err := m.GetSubscriptionInfo(userDID)
|
|
if err != nil || info == nil {
|
|
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
|
|
}
|
|
|
|
rank := info.TierRank
|
|
if rank >= 0 && rank < len(m.cfg.Tiers) {
|
|
return m.cfg.Tiers[rank].MaxWebhooks, m.cfg.Tiers[rank].WebhookAllTriggers
|
|
}
|
|
|
|
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
|
|
}
|
|
|
|
// HasAIAdvisor returns whether a user has access to the AI Image Advisor based on their subscription tier.
|
|
// Hold captains always have access.
|
|
func (m *Manager) HasAIAdvisor(userDID string) bool {
|
|
if m.isCaptain(userDID) {
|
|
return true
|
|
}
|
|
if !m.Enabled() {
|
|
return false
|
|
}
|
|
// Paid features require an active managed hold. Fall back to the free tier's
|
|
// setting rather than a hard false, matching GetWebhookLimits: an off-managed
|
|
// user should land on free-tier entitlements, not below them. (These differ
|
|
// only if a deployment turns AIAdvisor on for tier 0.)
|
|
if !m.onManagedHold(userDID) {
|
|
return m.cfg.Tiers[0].AIAdvisor
|
|
}
|
|
|
|
info, err := m.GetSubscriptionInfo(userDID)
|
|
if err != nil || info == nil {
|
|
return m.cfg.Tiers[0].AIAdvisor
|
|
}
|
|
|
|
rank := info.TierRank
|
|
if rank >= 0 && rank < len(m.cfg.Tiers) {
|
|
return m.cfg.Tiers[rank].AIAdvisor
|
|
}
|
|
|
|
return m.cfg.Tiers[0].AIAdvisor
|
|
}
|
|
|
|
// GetSupporterBadge returns the supporter badge tier name for a user based on their subscription.
|
|
// Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise.
|
|
// Hold captains get a "Captain" badge.
|
|
func (m *Manager) GetSupporterBadge(userDID string) string {
|
|
if m.isCaptain(userDID) {
|
|
return "Captain"
|
|
}
|
|
if !m.Enabled() {
|
|
return ""
|
|
}
|
|
// Paid features require an active managed hold.
|
|
if !m.onManagedHold(userDID) {
|
|
return ""
|
|
}
|
|
|
|
info, err := m.GetSubscriptionInfo(userDID)
|
|
if err != nil || info == nil {
|
|
return ""
|
|
}
|
|
|
|
for _, tier := range info.Tiers {
|
|
if tier.ID == info.CurrentTier && tier.SupporterBadge {
|
|
return info.CurrentTier
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// GetSubscriptionInfo returns subscription and tier information for a user.
|
|
// Hold captains see a special "Captain" tier with all features unlocked.
|
|
func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) {
|
|
if m.isCaptain(userDID) {
|
|
return &SubscriptionInfo{
|
|
UserDID: userDID,
|
|
CurrentTier: "Captain",
|
|
TierRank: -1, // above all configured tiers
|
|
Tiers: []TierInfo{{
|
|
ID: "Captain",
|
|
Name: "Captain",
|
|
Description: "Hold operator",
|
|
Features: []string{"Unlimited storage", "Unlimited webhooks", "All webhook triggers", "Scan on push"},
|
|
Rank: -1,
|
|
MaxWebhooks: -1,
|
|
WebhookAllTriggers: true,
|
|
SupporterBadge: true,
|
|
IsCurrent: true,
|
|
}},
|
|
}, nil
|
|
}
|
|
|
|
if !m.Enabled() {
|
|
return nil, ErrBillingDisabled
|
|
}
|
|
|
|
// Paid features require an active managed hold. Off-managed users still see
|
|
// the tier list (for context) but with payments disabled, which drives the
|
|
// billing UI to hide itself.
|
|
onManaged := m.onManagedHold(userDID)
|
|
|
|
info := &SubscriptionInfo{
|
|
UserDID: userDID,
|
|
PaymentsEnabled: onManaged,
|
|
CurrentTier: m.cfg.Tiers[0].Name, // default to lowest
|
|
TierRank: 0,
|
|
}
|
|
|
|
// Build tier list with live Stripe prices
|
|
info.Tiers = make([]TierInfo, len(m.cfg.Tiers))
|
|
for i, tier := range m.cfg.Tiers {
|
|
// Dynamic features: hold-derived first, then webhook limits, then static config
|
|
features := m.aggregateHoldFeatures(i)
|
|
features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...)
|
|
features = append(features, aiAdvisorFeatures(tier.AIAdvisor)...)
|
|
if tier.SupporterBadge {
|
|
features = append(features, "Supporter badge")
|
|
}
|
|
features = append(features, tier.Features...)
|
|
info.Tiers[i] = TierInfo{
|
|
ID: tier.Name,
|
|
Name: tier.Name,
|
|
Description: tier.Description,
|
|
Features: features,
|
|
Rank: i,
|
|
MaxWebhooks: tier.MaxWebhooks,
|
|
WebhookAllTriggers: tier.WebhookAllTriggers,
|
|
SupporterBadge: tier.SupporterBadge,
|
|
}
|
|
if tier.StripePriceMonthly != "" {
|
|
if amount, err := m.fetchPrice(tier.StripePriceMonthly); err == nil {
|
|
info.Tiers[i].PriceCentsMonthly = int(amount)
|
|
}
|
|
}
|
|
if tier.StripePriceYearly != "" {
|
|
if amount, err := m.fetchPrice(tier.StripePriceYearly); err == nil {
|
|
info.Tiers[i].PriceCentsYearly = int(amount)
|
|
}
|
|
}
|
|
}
|
|
|
|
if userDID == "" || !onManaged {
|
|
return info, nil
|
|
}
|
|
|
|
// Find Stripe customer for this user
|
|
cust, err := m.findCustomerByDID(userDID)
|
|
if err != nil {
|
|
slog.Debug("No Stripe customer found", "userDID", userDID, "error", err)
|
|
return info, nil
|
|
}
|
|
info.CustomerID = cust.ID
|
|
|
|
// Find active subscription
|
|
params := &stripe.SubscriptionListParams{}
|
|
params.Filters.AddFilter("customer", "", cust.ID)
|
|
params.Filters.AddFilter("status", "", "active")
|
|
iter := subscription.List(params)
|
|
|
|
// Use the first active subscription only.
|
|
if iter.Next() {
|
|
sub := iter.Subscription()
|
|
info.SubscriptionID = sub.ID
|
|
|
|
if sub.Items != nil && len(sub.Items.Data) > 0 {
|
|
priceID := sub.Items.Data[0].Price.ID
|
|
tierName, tierRank := m.cfg.GetTierByPriceID(priceID)
|
|
if tierName != "" {
|
|
info.CurrentTier = tierName
|
|
info.TierRank = tierRank
|
|
}
|
|
|
|
if sub.Items.Data[0].Price.Recurring != nil {
|
|
switch sub.Items.Data[0].Price.Recurring.Interval {
|
|
case stripe.PriceRecurringIntervalMonth:
|
|
info.BillingInterval = "monthly"
|
|
case stripe.PriceRecurringIntervalYear:
|
|
info.BillingInterval = "yearly"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark current tier
|
|
for i := range info.Tiers {
|
|
info.Tiers[i].IsCurrent = info.Tiers[i].ID == info.CurrentTier
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// HasActiveSubscription reports whether the user has an active Stripe
|
|
// subscription, regardless of which hold they're on. Unlike GetSubscriptionInfo
|
|
// this is NOT gated on managed-hold membership: it's used to warn a user who
|
|
// switched to a self-hosted hold that they're still being charged.
|
|
func (m *Manager) HasActiveSubscription(userDID string) bool {
|
|
if !m.Enabled() || userDID == "" {
|
|
return false
|
|
}
|
|
cust, err := m.findCustomerByDID(userDID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
params := &stripe.SubscriptionListParams{}
|
|
params.Filters.AddFilter("customer", "", cust.ID)
|
|
params.Filters.AddFilter("status", "", "active")
|
|
return subscription.List(params).Next()
|
|
}
|
|
|
|
// CreateCheckoutSession creates a Stripe checkout session for a subscription.
|
|
func (m *Manager) CreateCheckoutSession(r *http.Request, userDID, userHandle string, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
|
|
if !m.Enabled() {
|
|
return nil, ErrBillingDisabled
|
|
}
|
|
|
|
// Find the tier config
|
|
rank := m.cfg.TierRank(req.Tier)
|
|
if rank < 0 {
|
|
return nil, fmt.Errorf("unknown tier: %s", req.Tier)
|
|
}
|
|
tierCfg := m.cfg.Tiers[rank]
|
|
|
|
// Determine price ID: prefer monthly so Stripe upsell can offer yearly toggle,
|
|
// fall back to yearly if no monthly price exists.
|
|
var priceID string
|
|
if req.Interval == "yearly" && tierCfg.StripePriceYearly != "" {
|
|
priceID = tierCfg.StripePriceYearly
|
|
} else if tierCfg.StripePriceMonthly != "" {
|
|
priceID = tierCfg.StripePriceMonthly
|
|
} else if tierCfg.StripePriceYearly != "" {
|
|
priceID = tierCfg.StripePriceYearly
|
|
}
|
|
if priceID == "" {
|
|
return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier)
|
|
}
|
|
|
|
// Get or create Stripe customer
|
|
cust, err := m.getOrCreateCustomer(userDID, userHandle)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get/create customer: %w", err)
|
|
}
|
|
|
|
// Build success/cancel URLs
|
|
successURL := strings.ReplaceAll(m.cfg.SuccessURL, "{base_url}", m.baseURL)
|
|
cancelURL := strings.ReplaceAll(m.cfg.CancelURL, "{base_url}", m.baseURL)
|
|
|
|
params := &stripe.CheckoutSessionParams{
|
|
Customer: stripe.String(cust.ID),
|
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
|
{
|
|
Price: stripe.String(priceID),
|
|
Quantity: stripe.Int64(1),
|
|
},
|
|
},
|
|
SuccessURL: stripe.String(successURL),
|
|
CancelURL: stripe.String(cancelURL),
|
|
}
|
|
|
|
s, err := session.New(params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create checkout session: %w", err)
|
|
}
|
|
|
|
return &CheckoutSessionResponse{
|
|
CheckoutURL: s.URL,
|
|
SessionID: s.ID,
|
|
}, nil
|
|
}
|
|
|
|
// GetBillingPortalURL creates a Stripe billing portal session.
|
|
func (m *Manager) GetBillingPortalURL(userDID, returnURL string) (*BillingPortalResponse, error) {
|
|
if !m.Enabled() {
|
|
return nil, ErrBillingDisabled
|
|
}
|
|
|
|
cust, err := m.findCustomerByDID(userDID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no billing account found")
|
|
}
|
|
|
|
params := &stripe.BillingPortalSessionParams{
|
|
Customer: stripe.String(cust.ID),
|
|
ReturnURL: stripe.String(returnURL),
|
|
}
|
|
|
|
s, err := portalsession.New(params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create portal session: %w", err)
|
|
}
|
|
|
|
return &BillingPortalResponse{PortalURL: s.URL}, nil
|
|
}
|
|
|
|
// ErrWebhookSignature wraps signature/parse failures. These are client errors:
|
|
// the caller (not Stripe) sent a bad request, so the HTTP handler returns 400
|
|
// and Stripe should not retry.
|
|
var ErrWebhookSignature = errors.New("webhook signature verification failed")
|
|
|
|
// ErrWebhookProcessing wraps transient processing failures (DB, tier push to a
|
|
// hold). The HTTP handler returns 5xx so Stripe retries; idempotency makes the
|
|
// retry safe.
|
|
var ErrWebhookProcessing = errors.New("webhook processing failed")
|
|
|
|
// HandleWebhook processes a Stripe webhook event.
|
|
// On subscription changes, it pushes tier updates to all managed holds.
|
|
//
|
|
// Returns ErrWebhookSignature (-> 400, no retry) for bad signatures/parse, or a
|
|
// wrapped ErrWebhookProcessing (-> 5xx, Stripe retries) when applying the event
|
|
// failed. On success the event is recorded for idempotency so redeliveries are
|
|
// no-ops.
|
|
func (m *Manager) HandleWebhook(r *http.Request) error {
|
|
if !m.Enabled() {
|
|
return ErrBillingDisabled
|
|
}
|
|
|
|
// Fail closed: an empty secret makes signatures forgeable (Stripe HMACs with
|
|
// the empty key). Startup also guards this; this is defense in depth.
|
|
if m.webhookSecret == "" {
|
|
return fmt.Errorf("%w: webhook secret not configured", ErrWebhookProcessing)
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: read body: %v", ErrWebhookProcessing, err)
|
|
}
|
|
|
|
event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrWebhookSignature, err)
|
|
}
|
|
|
|
// Idempotency: skip events we've already successfully processed so a Stripe
|
|
// redelivery (or replay of a validly-signed event) is a no-op.
|
|
if m.db != nil && event.ID != "" {
|
|
seen, err := db.StripeEventSeen(m.db, event.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: idempotency check: %v", ErrWebhookProcessing, err)
|
|
}
|
|
if seen {
|
|
slog.Debug("Ignoring already-processed Stripe event", "eventID", event.ID, "type", event.Type)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var procErr error
|
|
switch event.Type {
|
|
case EventCheckoutSessionCompleted:
|
|
procErr = m.handleCheckoutCompleted(event)
|
|
case EventSubscriptionCreated,
|
|
EventSubscriptionUpdated,
|
|
EventSubscriptionDeleted,
|
|
EventSubscriptionPaused,
|
|
EventSubscriptionResumed:
|
|
procErr = m.handleSubscriptionChange(event)
|
|
case EventInvoicePaymentFailed:
|
|
procErr = m.handleInvoicePaymentFailed(event)
|
|
case EventChargeDisputeCreated:
|
|
procErr = m.handleChargeDisputeCreated(event)
|
|
default:
|
|
slog.Debug("Ignoring Stripe event", "type", event.Type)
|
|
}
|
|
if procErr != nil {
|
|
// Not recorded as processed: Stripe will retry and we'll re-apply.
|
|
return fmt.Errorf("%w: %v", ErrWebhookProcessing, procErr)
|
|
}
|
|
|
|
// Record success (idempotency + per-customer ordering). Best-effort: a record
|
|
// failure shouldn't fail an already-applied event, but log it.
|
|
if m.db != nil && event.ID != "" {
|
|
if err := db.RecordStripeEvent(m.db, event.ID, subscriptionEventCustomerID(event), int64(event.Created)); err != nil {
|
|
slog.Warn("Failed to record processed Stripe event", "eventID", event.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// subscriptionEventCustomerID extracts the Stripe customer ID from a
|
|
// subscription lifecycle event, or "" for other event types. Only subscription
|
|
// events use per-customer ordering, so other types are recorded with no
|
|
// customer (dedup still works on event_id).
|
|
func subscriptionEventCustomerID(event stripe.Event) string {
|
|
switch event.Type {
|
|
case EventSubscriptionCreated,
|
|
EventSubscriptionUpdated,
|
|
EventSubscriptionDeleted,
|
|
EventSubscriptionPaused,
|
|
EventSubscriptionResumed:
|
|
var sub stripe.Subscription
|
|
if err := json.Unmarshal(event.Data.Raw, &sub); err == nil && sub.Customer != nil {
|
|
return sub.Customer.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// handleCheckoutCompleted processes a checkout.session.completed event.
|
|
func (m *Manager) handleCheckoutCompleted(event stripe.Event) error {
|
|
var cs stripe.CheckoutSession
|
|
if err := json.Unmarshal(event.Data.Raw, &cs); err != nil {
|
|
slog.Error("Failed to parse checkout session", "error", err)
|
|
// Malformed payload: don't ask Stripe to retry a bad event.
|
|
return nil
|
|
}
|
|
|
|
slog.Info("Checkout completed", "customerID", cs.Customer.ID, "subscriptionID", cs.Subscription.ID)
|
|
|
|
// The subscription.created event will handle the tier update
|
|
return nil
|
|
}
|
|
|
|
// handleSubscriptionChange processes subscription lifecycle events.
|
|
func (m *Manager) handleSubscriptionChange(event stripe.Event) error {
|
|
var sub stripe.Subscription
|
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
|
slog.Error("Failed to parse subscription", "error", err)
|
|
// Malformed payload: don't ask Stripe to retry a bad event.
|
|
return nil
|
|
}
|
|
|
|
// Ordering guard: ignore an event older than the newest one we've already
|
|
// applied for this customer (Stripe does not guarantee delivery order, so a
|
|
// stale active could otherwise re-grant a canceled tier).
|
|
if m.db != nil && sub.Customer != nil && sub.Customer.ID != "" {
|
|
latest, err := db.LatestStripeEventCreatedForCustomer(m.db, sub.Customer.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("ordering check: %w", err)
|
|
}
|
|
if latest > 0 && int64(event.Created) < latest {
|
|
slog.Warn("Ignoring stale out-of-order subscription event",
|
|
"customerID", sub.Customer.ID,
|
|
"subscriptionID", sub.ID,
|
|
"eventCreated", event.Created,
|
|
"latestApplied", latest,
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// The ordering guard above already treats a nil customer as possible; this
|
|
// used to dereference it regardless and panic.
|
|
if sub.Customer == nil || sub.Customer.ID == "" {
|
|
slog.Warn("Subscription event carries no customer", "subscriptionID", sub.ID)
|
|
return nil
|
|
}
|
|
|
|
// Get user DID from customer metadata. A failed lookup must be retried:
|
|
// returning nil here records the event as processed, Stripe answers 200 and
|
|
// never redelivers, and a paid upgrade is lost to a transient API error.
|
|
userDID, err := m.getCustomerDID(sub.Customer.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve customer DID: %w", err)
|
|
}
|
|
if userDID == "" {
|
|
// A conclusion, not a failure: redelivery cannot conjure a user_did.
|
|
slog.Warn("No user DID found for Stripe customer", "customerID", sub.Customer.ID)
|
|
return nil
|
|
}
|
|
|
|
// Determine new tier from subscription
|
|
var tierName string
|
|
var tierRank int
|
|
|
|
switch sub.Status {
|
|
case stripe.SubscriptionStatusActive, stripe.SubscriptionStatusTrialing:
|
|
if sub.Items != nil && len(sub.Items.Data) > 0 {
|
|
tierName, tierRank = m.resolveTier(sub.Items.Data[0].Price)
|
|
}
|
|
|
|
case stripe.SubscriptionStatusPastDue:
|
|
// Stripe is retrying the card via Smart Retries (typically 1-3 weeks).
|
|
// Keep tier unchanged; customer receives dunning emails from Stripe.
|
|
// A later subscription.updated with unpaid will downgrade.
|
|
slog.Warn("Subscription past due, keeping tier during dunning",
|
|
"userDID", userDID,
|
|
"subscriptionID", sub.ID,
|
|
"customerID", sub.Customer.ID,
|
|
)
|
|
return nil
|
|
|
|
case stripe.SubscriptionStatusUnpaid,
|
|
stripe.SubscriptionStatusCanceled,
|
|
stripe.SubscriptionStatusPaused,
|
|
stripe.SubscriptionStatusIncompleteExpired:
|
|
// Dunning exhausted, cleanly canceled, paused without payment method,
|
|
// or initial payment never completed within the 23-hour window.
|
|
tierName = m.cfg.Tiers[0].Name
|
|
tierRank = 0
|
|
|
|
case stripe.SubscriptionStatusIncomplete:
|
|
// 23-hour window for initial payment to confirm. Don't grant tier yet:
|
|
// a later subscription.updated will fire with active or incomplete_expired.
|
|
slog.Info("Subscription incomplete, awaiting initial payment",
|
|
"userDID", userDID,
|
|
"subscriptionID", sub.ID,
|
|
)
|
|
return nil
|
|
|
|
default:
|
|
slog.Debug("Ignoring subscription status", "status", sub.Status, "subscriptionID", sub.ID)
|
|
return nil
|
|
}
|
|
|
|
if tierName == "" {
|
|
slog.Warn("Could not resolve tier from subscription",
|
|
"priceID", sub.Items.Data[0].Price.ID,
|
|
"productID", priceProductID(sub.Items.Data[0].Price),
|
|
"subscriptionID", sub.ID,
|
|
"customerID", sub.Customer.ID,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
slog.Info("Pushing tier update to managed holds",
|
|
"userDID", userDID,
|
|
"tierName", tierName,
|
|
"tierRank", tierRank,
|
|
"event", event.Type,
|
|
)
|
|
|
|
// Push tier update to all managed holds synchronously. If any hold fails
|
|
// (after retries) we return an error so the webhook 5xxs and Stripe retries
|
|
// the whole event; idempotency makes that retry safe. This closes the
|
|
// "paid but never received tier" hole the old fire-and-forget goroutine had.
|
|
//
|
|
// Bound the whole push so a hung hold can't block the webhook past Stripe's
|
|
// delivery timeout (which would pile up goroutines and trigger redelivery).
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := holdclient.UpdateCrewTierOnAllHolds(
|
|
ctx,
|
|
m.managedHolds,
|
|
userDID,
|
|
tierRank,
|
|
m.privateKey,
|
|
m.appviewDID,
|
|
); err != nil {
|
|
return fmt.Errorf("push tier to managed holds: %w", err)
|
|
}
|
|
|
|
// Invalidate customer cache
|
|
m.customerCacheMu.Lock()
|
|
delete(m.customerCache, userDID)
|
|
m.customerCacheMu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveTier maps a subscription's price onto a configured tier.
|
|
//
|
|
// The product is tried first and the price ID is the fallback. That ordering is
|
|
// what makes grandfathering work: a tier's prices change over its life, its
|
|
// product does not, and Stripe leaves existing subscribers on the price they
|
|
// signed up at forever. Matching on price alone would drop those subscribers
|
|
// out of their tier the moment a new price was introduced — the opposite of
|
|
// what a price change is meant to do.
|
|
//
|
|
// The fallback keeps configs that predate stripe_product resolving unchanged.
|
|
func (m *Manager) resolveTier(price *stripe.Price) (string, int) {
|
|
if price == nil {
|
|
return "", -1
|
|
}
|
|
if name, rank := m.cfg.GetTierByProductID(priceProductID(price)); name != "" {
|
|
return name, rank
|
|
}
|
|
return m.cfg.GetTierByPriceID(price.ID)
|
|
}
|
|
|
|
// priceProductID returns the product ID behind a price, or "" if absent.
|
|
// Stripe sends price.product as a bare ID string unless it is expanded, which
|
|
// stripe-go unmarshals into a Product carrying only that ID.
|
|
func priceProductID(price *stripe.Price) string {
|
|
if price == nil || price.Product == nil {
|
|
return ""
|
|
}
|
|
return price.Product.ID
|
|
}
|
|
|
|
// handleInvoicePaymentFailed logs a failed invoice payment.
|
|
// No tier change: handleSubscriptionChange reacts to the subsequent
|
|
// past_due → unpaid transition. Stripe Smart Retries handle retry cadence
|
|
// and customer-facing dunning emails.
|
|
func (m *Manager) handleInvoicePaymentFailed(event stripe.Event) error {
|
|
var inv stripe.Invoice
|
|
if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
|
|
slog.Error("Failed to parse invoice", "error", err)
|
|
return nil
|
|
}
|
|
|
|
customerID := ""
|
|
userDID := ""
|
|
if inv.Customer != nil {
|
|
customerID = inv.Customer.ID
|
|
// Log-only: a lookup failure here is not worth failing the webhook over,
|
|
// and the entry is still useful without the DID.
|
|
userDID, _ = m.getCustomerDID(customerID)
|
|
}
|
|
|
|
nextAttempt := int64(0)
|
|
if inv.NextPaymentAttempt != 0 {
|
|
nextAttempt = inv.NextPaymentAttempt
|
|
}
|
|
|
|
slog.Warn("Stripe invoice payment failed",
|
|
"userDID", userDID,
|
|
"customerID", customerID,
|
|
"invoiceID", inv.ID,
|
|
"subscriptionID", subscriptionIDFromInvoice(&inv),
|
|
"amountDue", inv.AmountDue,
|
|
"currency", inv.Currency,
|
|
"attemptCount", inv.AttemptCount,
|
|
"nextAttempt", nextAttempt,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// handleChargeDisputeCreated logs a new chargeback.
|
|
// No tier action: disputes can be won, and downgrade-then-revert is worse UX
|
|
// than waiting for resolution. Stripe emails the account owner by default
|
|
// (Dashboard → Settings → Team & security → Notifications → "Disputes and
|
|
// inquiries").
|
|
func (m *Manager) handleChargeDisputeCreated(event stripe.Event) error {
|
|
var dispute stripe.Dispute
|
|
if err := json.Unmarshal(event.Data.Raw, &dispute); err != nil {
|
|
slog.Error("Failed to parse dispute", "error", err)
|
|
return nil
|
|
}
|
|
|
|
customerID := ""
|
|
userDID := ""
|
|
if dispute.Charge != nil && dispute.Charge.Customer != nil {
|
|
customerID = dispute.Charge.Customer.ID
|
|
// Log-only, as above.
|
|
userDID, _ = m.getCustomerDID(customerID)
|
|
}
|
|
|
|
evidenceDueBy := int64(0)
|
|
if dispute.EvidenceDetails != nil {
|
|
evidenceDueBy = dispute.EvidenceDetails.DueBy
|
|
}
|
|
|
|
slog.Warn("Stripe chargeback opened",
|
|
"userDID", userDID,
|
|
"customerID", customerID,
|
|
"disputeID", dispute.ID,
|
|
"chargeID", chargeIDFromDispute(&dispute),
|
|
"amount", dispute.Amount,
|
|
"currency", dispute.Currency,
|
|
"reason", dispute.Reason,
|
|
"status", dispute.Status,
|
|
"evidenceDueBy", evidenceDueBy,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// subscriptionIDFromInvoice returns the subscription ID on an invoice, or "".
|
|
// In stripe-go v84+, the subscription lives under Invoice.Parent.SubscriptionDetails.
|
|
func subscriptionIDFromInvoice(inv *stripe.Invoice) string {
|
|
if inv == nil || inv.Parent == nil || inv.Parent.SubscriptionDetails == nil {
|
|
return ""
|
|
}
|
|
if inv.Parent.SubscriptionDetails.Subscription == nil {
|
|
return ""
|
|
}
|
|
return inv.Parent.SubscriptionDetails.Subscription.ID
|
|
}
|
|
|
|
// chargeIDFromDispute returns the charge ID on a dispute, or "".
|
|
func chargeIDFromDispute(d *stripe.Dispute) string {
|
|
if d == nil || d.Charge == nil {
|
|
return ""
|
|
}
|
|
return d.Charge.ID
|
|
}
|
|
|
|
// getOrCreateCustomer finds or creates a Stripe customer for a DID.
|
|
func (m *Manager) getOrCreateCustomer(userDID, userHandle string) (*stripe.Customer, error) {
|
|
// Check cache
|
|
m.customerCacheMu.RLock()
|
|
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
|
|
m.customerCacheMu.RUnlock()
|
|
return cached.customer, nil
|
|
}
|
|
m.customerCacheMu.RUnlock()
|
|
|
|
// Search Stripe. Uses the uncached variant: the cache was just checked.
|
|
cust, err := m.searchCustomerByDID(userDID)
|
|
if err == nil {
|
|
m.cacheCustomer(userDID, cust)
|
|
return cust, nil
|
|
}
|
|
|
|
// Create new customer
|
|
params := &stripe.CustomerParams{
|
|
Params: stripe.Params{
|
|
Metadata: map[string]string{
|
|
"user_did": userDID,
|
|
},
|
|
},
|
|
}
|
|
if userHandle != "" {
|
|
params.Name = stripe.String(userHandle)
|
|
}
|
|
|
|
cust, err = customer.New(params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Stripe customer: %w", err)
|
|
}
|
|
|
|
m.cacheCustomer(userDID, cust)
|
|
return cust, nil
|
|
}
|
|
|
|
// findCustomerByDID returns the Stripe customer whose metadata carries this DID,
|
|
// preferring the shared customer cache over a live search.
|
|
//
|
|
// The cache matters because this is now on a hot path: the webhook dispatcher
|
|
// consults GetWebhookLimits on every delivery, which lands here via
|
|
// GetSubscriptionInfo. Searching Stripe per dispatch would mean a rate-limited
|
|
// Search API call for every push and every scan record of every user who has a
|
|
// webhook configured.
|
|
func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) {
|
|
m.customerCacheMu.RLock()
|
|
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
|
|
m.customerCacheMu.RUnlock()
|
|
return cached.customer, nil
|
|
}
|
|
m.customerCacheMu.RUnlock()
|
|
|
|
cust, err := m.searchCustomerByDID(userDID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.cacheCustomer(userDID, cust)
|
|
return cust, nil
|
|
}
|
|
|
|
// searchCustomerByDID searches Stripe for a customer with matching DID metadata,
|
|
// bypassing the cache. Callers should prefer findCustomerByDID.
|
|
func (m *Manager) searchCustomerByDID(userDID string) (*stripe.Customer, error) {
|
|
// DIDs reaching here are OAuth-validated (the DID grammar forbids quotes),
|
|
// but escape defensively so the query's safety doesn't silently depend on a
|
|
// validator several layers away. Stripe search escapes ' and \ with a
|
|
// backslash.
|
|
escaped := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(userDID)
|
|
params := &stripe.CustomerSearchParams{
|
|
SearchParams: stripe.SearchParams{
|
|
Query: fmt.Sprintf("metadata['user_did']:'%s'", escaped),
|
|
},
|
|
}
|
|
|
|
iter := customer.Search(params)
|
|
for iter.Next() {
|
|
return iter.Customer(), nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("customer not found for DID %s", userDID)
|
|
}
|
|
|
|
// getCustomerDID retrieves the user DID from a Stripe customer's metadata.
|
|
// Returns ("", nil) when the customer exists but carries no user_did, and
|
|
// ("", err) when the lookup itself failed. Callers acting on the result must
|
|
// distinguish the two: the first is a conclusion, the second is a Stripe
|
|
// outage, and treating an outage as "not our customer" silently drops the
|
|
// subscription change with no redelivery.
|
|
func (m *Manager) getCustomerDID(customerID string) (string, error) {
|
|
cust, err := customer.Get(customerID, nil)
|
|
if err != nil {
|
|
slog.Error("Failed to get customer", "customerID", customerID, "error", err)
|
|
return "", fmt.Errorf("get customer %s: %w", customerID, err)
|
|
}
|
|
return cust.Metadata["user_did"], nil
|
|
}
|
|
|
|
// cacheCustomer stores a customer in the in-memory cache.
|
|
func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) {
|
|
m.customerCacheMu.Lock()
|
|
m.customerCache[userDID] = &cachedCustomer{
|
|
customer: cust,
|
|
expiresAt: time.Now().Add(customerCacheTTL),
|
|
}
|
|
m.customerCacheMu.Unlock()
|
|
}
|
|
|
|
const holdTierCacheTTL = 30 * time.Minute
|
|
|
|
// RefreshHoldTiers queries all managed holds for their tier definitions and caches the results.
|
|
// It runs once immediately (with retries for holds that aren't ready yet) and then
|
|
// periodically in the background.
|
|
// Safe to call from a goroutine.
|
|
func (m *Manager) RefreshHoldTiers() {
|
|
if !m.Enabled() || len(m.managedHolds) == 0 {
|
|
return
|
|
}
|
|
|
|
// On startup, retry a few times with backoff in case holds aren't ready yet.
|
|
// This is common in docker-compose where appview starts before the hold.
|
|
const maxRetries = 5
|
|
const initialDelay = 3 * time.Second
|
|
|
|
for attempt := range maxRetries {
|
|
m.refreshHoldTiersOnce()
|
|
|
|
// Check if all managed holds are cached
|
|
m.holdTierCacheMu.RLock()
|
|
allCached := len(m.holdTierCache) == len(m.managedHolds)
|
|
m.holdTierCacheMu.RUnlock()
|
|
|
|
if allCached {
|
|
break
|
|
}
|
|
|
|
if attempt < maxRetries-1 {
|
|
delay := initialDelay * time.Duration(1<<attempt) // 3s, 6s, 12s, 24s
|
|
slog.Info("Some managed holds not yet reachable, retrying",
|
|
"attempt", attempt+1, "maxRetries", maxRetries, "retryIn", delay)
|
|
time.Sleep(delay)
|
|
}
|
|
}
|
|
|
|
ticker := time.NewTicker(holdTierCacheTTL)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
m.refreshHoldTiersOnce()
|
|
}
|
|
}
|
|
|
|
func (m *Manager) refreshHoldTiersOnce() {
|
|
for _, holdDID := range m.managedHolds {
|
|
resp, err := holdclient.ListTiers(context.Background(), holdDID)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch tiers from hold", "holdDID", holdDID, "error", err)
|
|
continue
|
|
}
|
|
|
|
m.holdTierCacheMu.Lock()
|
|
m.holdTierCache[holdDID] = &cachedHoldTiers{
|
|
tiers: resp.Tiers,
|
|
expiresAt: time.Now().Add(holdTierCacheTTL),
|
|
}
|
|
m.holdTierCacheMu.Unlock()
|
|
|
|
slog.Debug("Cached tier data from hold", "holdDID", holdDID, "tierCount", len(resp.Tiers))
|
|
}
|
|
}
|
|
|
|
// aggregateHoldFeatures generates dynamic feature strings for a tier rank
|
|
// by aggregating data from all cached managed holds.
|
|
// Returns nil if no hold data is available.
|
|
func (m *Manager) aggregateHoldFeatures(rank int) []string {
|
|
m.holdTierCacheMu.RLock()
|
|
defer m.holdTierCacheMu.RUnlock()
|
|
|
|
if len(m.holdTierCache) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
minQuota int64 = -1
|
|
maxQuota int64
|
|
scanCount int
|
|
totalHolds int
|
|
)
|
|
|
|
for _, cached := range m.holdTierCache {
|
|
if time.Now().After(cached.expiresAt) {
|
|
continue
|
|
}
|
|
if rank >= len(cached.tiers) {
|
|
continue
|
|
}
|
|
totalHolds++
|
|
tier := cached.tiers[rank]
|
|
|
|
if minQuota < 0 || tier.QuotaBytes < minQuota {
|
|
minQuota = tier.QuotaBytes
|
|
}
|
|
if tier.QuotaBytes > maxQuota {
|
|
maxQuota = tier.QuotaBytes
|
|
}
|
|
if tier.ScanOnPush {
|
|
scanCount++
|
|
}
|
|
}
|
|
|
|
if totalHolds == 0 {
|
|
return nil
|
|
}
|
|
|
|
var features []string
|
|
|
|
// Storage feature
|
|
if minQuota == maxQuota {
|
|
features = append(features, formatBytes(minQuota)+" storage")
|
|
} else {
|
|
features = append(features, formatBytes(minQuota)+"-"+formatBytes(maxQuota)+" storage")
|
|
}
|
|
|
|
// Scan on push feature
|
|
if scanCount == totalHolds {
|
|
features = append(features, "Scan on push")
|
|
} else if scanCount*2 >= totalHolds {
|
|
features = append(features, "Scan on push (most regions)")
|
|
} else if scanCount > 0 {
|
|
features = append(features, "Scan on push (some regions)")
|
|
}
|
|
|
|
return features
|
|
}
|
|
|
|
// webhookFeatures generates feature bullet strings for webhook limits.
|
|
func webhookFeatures(maxWebhooks int, allTriggers bool) []string {
|
|
var features []string
|
|
switch {
|
|
case maxWebhooks < 0:
|
|
features = append(features, "Unlimited webhooks")
|
|
case maxWebhooks == 1:
|
|
features = append(features, "1 webhook")
|
|
case maxWebhooks > 1:
|
|
features = append(features, fmt.Sprintf("%d webhooks", maxWebhooks))
|
|
}
|
|
if allTriggers {
|
|
features = append(features, "All webhook triggers")
|
|
}
|
|
return features
|
|
}
|
|
|
|
// aiAdvisorFeatures generates feature bullet strings for AI advisor access.
|
|
func aiAdvisorFeatures(enabled bool) []string {
|
|
if enabled {
|
|
return []string{"AI Image Advisor"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// formatBytes formats bytes as a human-readable string (e.g. "5.0 GB").
|
|
func formatBytes(b int64) string {
|
|
const unit = 1024
|
|
if b < unit {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := b / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
units := []string{"KB", "MB", "GB", "TB", "PB"}
|
|
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
|
|
}
|
|
|
|
// GetFirstTierWithAllTriggers returns the name of the lowest-rank tier that has
|
|
// webhook_all_triggers enabled. Returns empty string if none found.
|
|
func (m *Manager) GetFirstTierWithAllTriggers() string {
|
|
if !m.Enabled() {
|
|
return ""
|
|
}
|
|
for _, tier := range m.cfg.Tiers {
|
|
if tier.WebhookAllTriggers {
|
|
return tier.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache.
|
|
func (m *Manager) fetchPrice(priceID string) (int64, error) {
|
|
m.priceCacheMu.RLock()
|
|
if cached, ok := m.priceCache[priceID]; ok && time.Now().Before(cached.expiresAt) {
|
|
m.priceCacheMu.RUnlock()
|
|
return cached.unitAmount, nil
|
|
}
|
|
m.priceCacheMu.RUnlock()
|
|
|
|
p, err := price.Get(priceID, nil)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch Stripe price", "priceID", priceID, "error", err)
|
|
return 0, err
|
|
}
|
|
|
|
m.priceCacheMu.Lock()
|
|
m.priceCache[priceID] = &cachedPrice{
|
|
unitAmount: p.UnitAmount,
|
|
expiresAt: time.Now().Add(priceCacheTTL),
|
|
}
|
|
m.priceCacheMu.Unlock()
|
|
|
|
return p.UnitAmount, nil
|
|
}
|