A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
at main 744 lines 21 kB view raw
1//go:build billing 2 3package billing 4 5import ( 6 "context" 7 "encoding/json" 8 "fmt" 9 "io" 10 "log/slog" 11 "net/http" 12 "os" 13 "strings" 14 "sync" 15 "time" 16 17 "atcr.io/pkg/appview/holdclient" 18 "github.com/bluesky-social/indigo/atproto/atcrypto" 19 20 "github.com/stripe/stripe-go/v84" 21 portalsession "github.com/stripe/stripe-go/v84/billingportal/session" 22 "github.com/stripe/stripe-go/v84/checkout/session" 23 "github.com/stripe/stripe-go/v84/customer" 24 "github.com/stripe/stripe-go/v84/price" 25 "github.com/stripe/stripe-go/v84/subscription" 26 "github.com/stripe/stripe-go/v84/webhook" 27) 28 29// Manager handles Stripe billing and pushes tier updates to managed holds. 30type Manager struct { 31 cfg *Config 32 privateKey *atcrypto.PrivateKeyP256 33 appviewDID string 34 managedHolds []string 35 baseURL string 36 stripeKey string 37 webhookSecret string 38 39 // Captain checker: bypasses billing for hold owners 40 captainChecker CaptainChecker 41 42 // Customer cache: DID → Stripe customer 43 customerCache map[string]*cachedCustomer 44 customerCacheMu sync.RWMutex 45 46 // Price cache: Stripe price ID → unit amount in cents 47 priceCache map[string]*cachedPrice 48 priceCacheMu sync.RWMutex 49 50 // Hold tier cache: holdDID → tier list 51 holdTierCache map[string]*cachedHoldTiers 52 holdTierCacheMu sync.RWMutex 53} 54 55type cachedHoldTiers struct { 56 tiers []holdclient.HoldTierInfo 57 expiresAt time.Time 58} 59 60type cachedCustomer struct { 61 customer *stripe.Customer 62 expiresAt time.Time 63} 64 65type cachedPrice struct { 66 unitAmount int64 67 expiresAt time.Time 68} 69 70const customerCacheTTL = 10 * time.Minute 71const priceCacheTTL = 1 * time.Hour 72 73// New creates a new billing manager with Stripe integration. 74// Env vars STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET take precedence over config values. 75func New(cfg *Config, privateKey *atcrypto.PrivateKeyP256, appviewDID string, managedHolds []string, baseURL string) *Manager { 76 stripeKey := os.Getenv("STRIPE_SECRET_KEY") 77 if stripeKey == "" { 78 stripeKey = cfg.StripeSecretKey 79 } 80 if stripeKey != "" { 81 stripe.Key = stripeKey 82 } 83 84 webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") 85 if webhookSecret == "" { 86 webhookSecret = cfg.WebhookSecret 87 } 88 89 return &Manager{ 90 cfg: cfg, 91 privateKey: privateKey, 92 appviewDID: appviewDID, 93 managedHolds: managedHolds, 94 baseURL: baseURL, 95 stripeKey: stripeKey, 96 webhookSecret: webhookSecret, 97 customerCache: make(map[string]*cachedCustomer), 98 priceCache: make(map[string]*cachedPrice), 99 holdTierCache: make(map[string]*cachedHoldTiers), 100 } 101} 102 103// SetCaptainChecker sets a callback that checks if a user is a hold captain. 104// Captains bypass all billing feature gates. 105func (m *Manager) SetCaptainChecker(fn CaptainChecker) { 106 m.captainChecker = fn 107} 108 109func (m *Manager) isCaptain(userDID string) bool { 110 return m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) 111} 112 113// Enabled returns true if billing is properly configured. 114func (m *Manager) Enabled() bool { 115 return m.cfg != nil && m.stripeKey != "" && len(m.cfg.Tiers) > 0 116} 117 118// GetWebhookLimits returns webhook limits for a user based on their subscription tier. 119// Returns (maxWebhooks, allTriggers). Defaults to the lowest tier's limits. 120// Hold captains get unlimited webhooks with all triggers. 121func (m *Manager) GetWebhookLimits(userDID string) (int, bool) { 122 if m.isCaptain(userDID) { 123 return -1, true // unlimited 124 } 125 if !m.Enabled() { 126 return 1, false 127 } 128 129 info, err := m.GetSubscriptionInfo(userDID) 130 if err != nil || info == nil { 131 return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers 132 } 133 134 rank := info.TierRank 135 if rank >= 0 && rank < len(m.cfg.Tiers) { 136 return m.cfg.Tiers[rank].MaxWebhooks, m.cfg.Tiers[rank].WebhookAllTriggers 137 } 138 139 return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers 140} 141 142// GetSupporterBadge returns the supporter badge tier name for a user based on their subscription. 143// Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise. 144// Hold captains get a "Captain" badge. 145func (m *Manager) GetSupporterBadge(userDID string) string { 146 if m.isCaptain(userDID) { 147 return "Captain" 148 } 149 if !m.Enabled() { 150 return "" 151 } 152 153 info, err := m.GetSubscriptionInfo(userDID) 154 if err != nil || info == nil { 155 return "" 156 } 157 158 for _, tier := range info.Tiers { 159 if tier.ID == info.CurrentTier && tier.SupporterBadge { 160 return info.CurrentTier 161 } 162 } 163 164 return "" 165} 166 167// GetSubscriptionInfo returns subscription and tier information for a user. 168// Hold captains see a special "Captain" tier with all features unlocked. 169func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) { 170 if m.isCaptain(userDID) { 171 return &SubscriptionInfo{ 172 UserDID: userDID, 173 CurrentTier: "Captain", 174 TierRank: -1, // above all configured tiers 175 Tiers: []TierInfo{{ 176 ID: "Captain", 177 Name: "Captain", 178 Description: "Hold operator", 179 Features: []string{"Unlimited storage", "Unlimited webhooks", "All webhook triggers", "Scan on push"}, 180 Rank: -1, 181 MaxWebhooks: -1, 182 WebhookAllTriggers: true, 183 SupporterBadge: true, 184 IsCurrent: true, 185 }}, 186 }, nil 187 } 188 189 if !m.Enabled() { 190 return nil, ErrBillingDisabled 191 } 192 193 info := &SubscriptionInfo{ 194 UserDID: userDID, 195 PaymentsEnabled: true, 196 CurrentTier: m.cfg.Tiers[0].Name, // default to lowest 197 TierRank: 0, 198 } 199 200 // Build tier list with live Stripe prices 201 info.Tiers = make([]TierInfo, len(m.cfg.Tiers)) 202 for i, tier := range m.cfg.Tiers { 203 // Dynamic features: hold-derived first, then webhook limits, then static config 204 features := m.aggregateHoldFeatures(i) 205 features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...) 206 if tier.SupporterBadge { 207 features = append(features, "Supporter badge") 208 } 209 features = append(features, tier.Features...) 210 info.Tiers[i] = TierInfo{ 211 ID: tier.Name, 212 Name: tier.Name, 213 Description: tier.Description, 214 Features: features, 215 Rank: i, 216 MaxWebhooks: tier.MaxWebhooks, 217 WebhookAllTriggers: tier.WebhookAllTriggers, 218 SupporterBadge: tier.SupporterBadge, 219 } 220 if tier.StripePriceMonthly != "" { 221 if amount, err := m.fetchPrice(tier.StripePriceMonthly); err == nil { 222 info.Tiers[i].PriceCentsMonthly = int(amount) 223 } 224 } 225 if tier.StripePriceYearly != "" { 226 if amount, err := m.fetchPrice(tier.StripePriceYearly); err == nil { 227 info.Tiers[i].PriceCentsYearly = int(amount) 228 } 229 } 230 } 231 232 if userDID == "" { 233 return info, nil 234 } 235 236 // Find Stripe customer for this user 237 cust, err := m.findCustomerByDID(userDID) 238 if err != nil { 239 slog.Debug("No Stripe customer found", "userDID", userDID, "error", err) 240 return info, nil 241 } 242 info.CustomerID = cust.ID 243 244 // Find active subscription 245 params := &stripe.SubscriptionListParams{} 246 params.Filters.AddFilter("customer", "", cust.ID) 247 params.Filters.AddFilter("status", "", "active") 248 iter := subscription.List(params) 249 250 for iter.Next() { 251 sub := iter.Subscription() 252 info.SubscriptionID = sub.ID 253 254 if sub.Items != nil && len(sub.Items.Data) > 0 { 255 priceID := sub.Items.Data[0].Price.ID 256 tierName, tierRank := m.cfg.GetTierByPriceID(priceID) 257 if tierName != "" { 258 info.CurrentTier = tierName 259 info.TierRank = tierRank 260 } 261 262 if sub.Items.Data[0].Price.Recurring != nil { 263 switch sub.Items.Data[0].Price.Recurring.Interval { 264 case stripe.PriceRecurringIntervalMonth: 265 info.BillingInterval = "monthly" 266 case stripe.PriceRecurringIntervalYear: 267 info.BillingInterval = "yearly" 268 } 269 } 270 } 271 break 272 } 273 274 // Mark current tier 275 for i := range info.Tiers { 276 info.Tiers[i].IsCurrent = info.Tiers[i].ID == info.CurrentTier 277 } 278 279 return info, nil 280} 281 282// CreateCheckoutSession creates a Stripe checkout session for a subscription. 283func (m *Manager) CreateCheckoutSession(r *http.Request, userDID, userHandle string, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { 284 if !m.Enabled() { 285 return nil, ErrBillingDisabled 286 } 287 288 // Find the tier config 289 rank := m.cfg.TierRank(req.Tier) 290 if rank < 0 { 291 return nil, fmt.Errorf("unknown tier: %s", req.Tier) 292 } 293 tierCfg := m.cfg.Tiers[rank] 294 295 // Determine price ID: prefer monthly so Stripe upsell can offer yearly toggle, 296 // fall back to yearly if no monthly price exists. 297 var priceID string 298 if req.Interval == "yearly" && tierCfg.StripePriceYearly != "" { 299 priceID = tierCfg.StripePriceYearly 300 } else if tierCfg.StripePriceMonthly != "" { 301 priceID = tierCfg.StripePriceMonthly 302 } else if tierCfg.StripePriceYearly != "" { 303 priceID = tierCfg.StripePriceYearly 304 } 305 if priceID == "" { 306 return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier) 307 } 308 309 // Get or create Stripe customer 310 cust, err := m.getOrCreateCustomer(userDID, userHandle) 311 if err != nil { 312 return nil, fmt.Errorf("failed to get/create customer: %w", err) 313 } 314 315 // Build success/cancel URLs 316 successURL := strings.ReplaceAll(m.cfg.SuccessURL, "{base_url}", m.baseURL) 317 cancelURL := strings.ReplaceAll(m.cfg.CancelURL, "{base_url}", m.baseURL) 318 319 params := &stripe.CheckoutSessionParams{ 320 Customer: stripe.String(cust.ID), 321 Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), 322 LineItems: []*stripe.CheckoutSessionLineItemParams{ 323 { 324 Price: stripe.String(priceID), 325 Quantity: stripe.Int64(1), 326 }, 327 }, 328 SuccessURL: stripe.String(successURL), 329 CancelURL: stripe.String(cancelURL), 330 } 331 332 s, err := session.New(params) 333 if err != nil { 334 return nil, fmt.Errorf("failed to create checkout session: %w", err) 335 } 336 337 return &CheckoutSessionResponse{ 338 CheckoutURL: s.URL, 339 SessionID: s.ID, 340 }, nil 341} 342 343// GetBillingPortalURL creates a Stripe billing portal session. 344func (m *Manager) GetBillingPortalURL(userDID, returnURL string) (*BillingPortalResponse, error) { 345 if !m.Enabled() { 346 return nil, ErrBillingDisabled 347 } 348 349 cust, err := m.findCustomerByDID(userDID) 350 if err != nil { 351 return nil, fmt.Errorf("no billing account found") 352 } 353 354 params := &stripe.BillingPortalSessionParams{ 355 Customer: stripe.String(cust.ID), 356 ReturnURL: stripe.String(returnURL), 357 } 358 359 s, err := portalsession.New(params) 360 if err != nil { 361 return nil, fmt.Errorf("failed to create portal session: %w", err) 362 } 363 364 return &BillingPortalResponse{PortalURL: s.URL}, nil 365} 366 367// HandleWebhook processes a Stripe webhook event. 368// On subscription changes, it pushes tier updates to all managed holds. 369func (m *Manager) HandleWebhook(r *http.Request) error { 370 if !m.Enabled() { 371 return ErrBillingDisabled 372 } 373 374 body, err := io.ReadAll(r.Body) 375 if err != nil { 376 return fmt.Errorf("failed to read webhook body: %w", err) 377 } 378 379 event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret) 380 if err != nil { 381 return fmt.Errorf("webhook signature verification failed: %w", err) 382 } 383 384 switch event.Type { 385 case "checkout.session.completed": 386 m.handleCheckoutCompleted(event) 387 case "customer.subscription.created", 388 "customer.subscription.updated", 389 "customer.subscription.deleted", 390 "customer.subscription.paused", 391 "customer.subscription.resumed": 392 m.handleSubscriptionChange(event) 393 default: 394 slog.Debug("Ignoring Stripe event", "type", event.Type) 395 } 396 397 return nil 398} 399 400// handleCheckoutCompleted processes a checkout.session.completed event. 401func (m *Manager) handleCheckoutCompleted(event stripe.Event) { 402 var cs stripe.CheckoutSession 403 if err := json.Unmarshal(event.Data.Raw, &cs); err != nil { 404 slog.Error("Failed to parse checkout session", "error", err) 405 return 406 } 407 408 slog.Info("Checkout completed", "customerID", cs.Customer.ID, "subscriptionID", cs.Subscription.ID) 409 410 // The subscription.created event will handle the tier update 411} 412 413// handleSubscriptionChange processes subscription lifecycle events. 414func (m *Manager) handleSubscriptionChange(event stripe.Event) { 415 var sub stripe.Subscription 416 if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { 417 slog.Error("Failed to parse subscription", "error", err) 418 return 419 } 420 421 // Get user DID from customer metadata 422 userDID := m.getCustomerDID(sub.Customer.ID) 423 if userDID == "" { 424 slog.Warn("No user DID found for Stripe customer", "customerID", sub.Customer.ID) 425 return 426 } 427 428 // Determine new tier from subscription 429 var tierName string 430 var tierRank int 431 432 switch sub.Status { 433 case stripe.SubscriptionStatusActive: 434 if sub.Items != nil && len(sub.Items.Data) > 0 { 435 priceID := sub.Items.Data[0].Price.ID 436 tierName, tierRank = m.cfg.GetTierByPriceID(priceID) 437 } 438 case stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusPaused: 439 // Revert to free tier (rank 0) 440 tierName = m.cfg.Tiers[0].Name 441 tierRank = 0 442 default: 443 slog.Debug("Ignoring subscription status", "status", sub.Status) 444 return 445 } 446 447 if tierName == "" { 448 slog.Warn("Could not resolve tier from subscription", "priceID", sub.Items.Data[0].Price.ID) 449 return 450 } 451 452 slog.Info("Pushing tier update to managed holds", 453 "userDID", userDID, 454 "tierName", tierName, 455 "tierRank", tierRank, 456 "event", event.Type, 457 ) 458 459 // Push tier update to all managed holds 460 go holdclient.UpdateCrewTierOnAllHolds( 461 context.Background(), 462 m.managedHolds, 463 userDID, 464 tierRank, 465 m.privateKey, 466 m.appviewDID, 467 ) 468 469 // Invalidate customer cache 470 m.customerCacheMu.Lock() 471 delete(m.customerCache, userDID) 472 m.customerCacheMu.Unlock() 473} 474 475// getOrCreateCustomer finds or creates a Stripe customer for a DID. 476func (m *Manager) getOrCreateCustomer(userDID, userHandle string) (*stripe.Customer, error) { 477 // Check cache 478 m.customerCacheMu.RLock() 479 if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { 480 m.customerCacheMu.RUnlock() 481 return cached.customer, nil 482 } 483 m.customerCacheMu.RUnlock() 484 485 // Search Stripe 486 cust, err := m.findCustomerByDID(userDID) 487 if err == nil { 488 m.cacheCustomer(userDID, cust) 489 return cust, nil 490 } 491 492 // Create new customer 493 params := &stripe.CustomerParams{ 494 Params: stripe.Params{ 495 Metadata: map[string]string{ 496 "user_did": userDID, 497 }, 498 }, 499 } 500 if userHandle != "" { 501 params.Name = stripe.String(userHandle) 502 } 503 504 cust, err = customer.New(params) 505 if err != nil { 506 return nil, fmt.Errorf("failed to create Stripe customer: %w", err) 507 } 508 509 m.cacheCustomer(userDID, cust) 510 return cust, nil 511} 512 513// findCustomerByDID searches Stripe for a customer with matching DID metadata. 514func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) { 515 params := &stripe.CustomerSearchParams{ 516 SearchParams: stripe.SearchParams{ 517 Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID), 518 }, 519 } 520 521 iter := customer.Search(params) 522 for iter.Next() { 523 return iter.Customer(), nil 524 } 525 526 return nil, fmt.Errorf("customer not found for DID %s", userDID) 527} 528 529// getCustomerDID retrieves the user DID from a Stripe customer's metadata. 530func (m *Manager) getCustomerDID(customerID string) string { 531 cust, err := customer.Get(customerID, nil) 532 if err != nil { 533 slog.Error("Failed to get customer", "customerID", customerID, "error", err) 534 return "" 535 } 536 return cust.Metadata["user_did"] 537} 538 539// cacheCustomer stores a customer in the in-memory cache. 540func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) { 541 m.customerCacheMu.Lock() 542 m.customerCache[userDID] = &cachedCustomer{ 543 customer: cust, 544 expiresAt: time.Now().Add(customerCacheTTL), 545 } 546 m.customerCacheMu.Unlock() 547} 548 549const holdTierCacheTTL = 30 * time.Minute 550 551// RefreshHoldTiers queries all managed holds for their tier definitions and caches the results. 552// It runs once immediately (with retries for holds that aren't ready yet) and then 553// periodically in the background. 554// Safe to call from a goroutine. 555func (m *Manager) RefreshHoldTiers() { 556 if !m.Enabled() || len(m.managedHolds) == 0 { 557 return 558 } 559 560 // On startup, retry a few times with backoff in case holds aren't ready yet. 561 // This is common in docker-compose where appview starts before the hold. 562 const maxRetries = 5 563 const initialDelay = 3 * time.Second 564 565 for attempt := range maxRetries { 566 m.refreshHoldTiersOnce() 567 568 // Check if all managed holds are cached 569 m.holdTierCacheMu.RLock() 570 allCached := len(m.holdTierCache) == len(m.managedHolds) 571 m.holdTierCacheMu.RUnlock() 572 573 if allCached { 574 break 575 } 576 577 if attempt < maxRetries-1 { 578 delay := initialDelay * time.Duration(1<<attempt) // 3s, 6s, 12s, 24s 579 slog.Info("Some managed holds not yet reachable, retrying", 580 "attempt", attempt+1, "maxRetries", maxRetries, "retryIn", delay) 581 time.Sleep(delay) 582 } 583 } 584 585 ticker := time.NewTicker(holdTierCacheTTL) 586 defer ticker.Stop() 587 for range ticker.C { 588 m.refreshHoldTiersOnce() 589 } 590} 591 592func (m *Manager) refreshHoldTiersOnce() { 593 for _, holdDID := range m.managedHolds { 594 resp, err := holdclient.ListTiers(context.Background(), holdDID) 595 if err != nil { 596 slog.Warn("Failed to fetch tiers from hold", "holdDID", holdDID, "error", err) 597 continue 598 } 599 600 m.holdTierCacheMu.Lock() 601 m.holdTierCache[holdDID] = &cachedHoldTiers{ 602 tiers: resp.Tiers, 603 expiresAt: time.Now().Add(holdTierCacheTTL), 604 } 605 m.holdTierCacheMu.Unlock() 606 607 slog.Debug("Cached tier data from hold", "holdDID", holdDID, "tierCount", len(resp.Tiers)) 608 } 609} 610 611// aggregateHoldFeatures generates dynamic feature strings for a tier rank 612// by aggregating data from all cached managed holds. 613// Returns nil if no hold data is available. 614func (m *Manager) aggregateHoldFeatures(rank int) []string { 615 m.holdTierCacheMu.RLock() 616 defer m.holdTierCacheMu.RUnlock() 617 618 if len(m.holdTierCache) == 0 { 619 return nil 620 } 621 622 var ( 623 minQuota int64 = -1 624 maxQuota int64 625 scanCount int 626 totalHolds int 627 ) 628 629 for _, cached := range m.holdTierCache { 630 if time.Now().After(cached.expiresAt) { 631 continue 632 } 633 if rank >= len(cached.tiers) { 634 continue 635 } 636 totalHolds++ 637 tier := cached.tiers[rank] 638 639 if minQuota < 0 || tier.QuotaBytes < minQuota { 640 minQuota = tier.QuotaBytes 641 } 642 if tier.QuotaBytes > maxQuota { 643 maxQuota = tier.QuotaBytes 644 } 645 if tier.ScanOnPush { 646 scanCount++ 647 } 648 } 649 650 if totalHolds == 0 { 651 return nil 652 } 653 654 var features []string 655 656 // Storage feature 657 if minQuota == maxQuota { 658 features = append(features, formatBytes(minQuota)+" storage") 659 } else { 660 features = append(features, formatBytes(minQuota)+"-"+formatBytes(maxQuota)+" storage") 661 } 662 663 // Scan on push feature 664 if scanCount == totalHolds { 665 features = append(features, "Scan on push") 666 } else if scanCount*2 >= totalHolds { 667 features = append(features, "Scan on push (most regions)") 668 } else if scanCount > 0 { 669 features = append(features, "Scan on push (some regions)") 670 } 671 672 return features 673} 674 675// webhookFeatures generates feature bullet strings for webhook limits. 676func webhookFeatures(maxWebhooks int, allTriggers bool) []string { 677 var features []string 678 switch { 679 case maxWebhooks < 0: 680 features = append(features, "Unlimited webhooks") 681 case maxWebhooks == 1: 682 features = append(features, "1 webhook") 683 case maxWebhooks > 1: 684 features = append(features, fmt.Sprintf("%d webhooks", maxWebhooks)) 685 } 686 if allTriggers { 687 features = append(features, "All webhook triggers") 688 } 689 return features 690} 691 692// formatBytes formats bytes as a human-readable string (e.g. "5.0 GB"). 693func formatBytes(b int64) string { 694 const unit = 1024 695 if b < unit { 696 return fmt.Sprintf("%d B", b) 697 } 698 div, exp := int64(unit), 0 699 for n := b / unit; n >= unit; n /= unit { 700 div *= unit 701 exp++ 702 } 703 units := []string{"KB", "MB", "GB", "TB", "PB"} 704 return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp]) 705} 706 707// GetFirstTierWithAllTriggers returns the name of the lowest-rank tier that has 708// webhook_all_triggers enabled. Returns empty string if none found. 709func (m *Manager) GetFirstTierWithAllTriggers() string { 710 if !m.Enabled() { 711 return "" 712 } 713 for _, tier := range m.cfg.Tiers { 714 if tier.WebhookAllTriggers { 715 return tier.Name 716 } 717 } 718 return "" 719} 720 721// fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache. 722func (m *Manager) fetchPrice(priceID string) (int64, error) { 723 m.priceCacheMu.RLock() 724 if cached, ok := m.priceCache[priceID]; ok && time.Now().Before(cached.expiresAt) { 725 m.priceCacheMu.RUnlock() 726 return cached.unitAmount, nil 727 } 728 m.priceCacheMu.RUnlock() 729 730 p, err := price.Get(priceID, nil) 731 if err != nil { 732 slog.Warn("Failed to fetch Stripe price", "priceID", priceID, "error", err) 733 return 0, err 734 } 735 736 m.priceCacheMu.Lock() 737 m.priceCache[priceID] = &cachedPrice{ 738 unitAmount: p.UnitAmount, 739 expiresAt: time.Now().Add(priceCacheTTL), 740 } 741 m.priceCacheMu.Unlock() 742 743 return p.UnitAmount, nil 744}