A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
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}