···11+package supporter
22+33+import (
44+ "fmt"
55+ "sync"
66+ "time"
77+88+ "github.com/shindakun/attodo/internal/database"
99+ "github.com/shindakun/attodo/internal/models"
1010+)
1111+1212+// Service handles supporter business logic with caching
1313+type Service struct {
1414+ repo *database.SupporterRepo
1515+ cache map[string]*cachedStatus
1616+ mu sync.RWMutex
1717+}
1818+1919+type cachedStatus struct {
2020+ IsActive bool
2121+ ExpiresAt time.Time
2222+}
2323+2424+// NewService creates a new supporter service
2525+func NewService(repo *database.SupporterRepo) *Service {
2626+ return &Service{
2727+ repo: repo,
2828+ cache: make(map[string]*cachedStatus),
2929+ }
3030+}
3131+3232+// IsSupporter checks if a user is an active supporter (with caching)
3333+func (s *Service) IsSupporter(did string) (bool, error) {
3434+ // Check cache first
3535+ s.mu.RLock()
3636+ if cached, ok := s.cache[did]; ok && time.Now().Before(cached.ExpiresAt) {
3737+ s.mu.RUnlock()
3838+ return cached.IsActive, nil
3939+ }
4040+ s.mu.RUnlock()
4141+4242+ // Query database
4343+ isActive, err := s.repo.IsSupporter(did)
4444+ if err != nil {
4545+ return false, err
4646+ }
4747+4848+ // Cache result for 5 minutes
4949+ s.mu.Lock()
5050+ s.cache[did] = &cachedStatus{
5151+ IsActive: isActive,
5252+ ExpiresAt: time.Now().Add(5 * time.Minute),
5353+ }
5454+ s.mu.Unlock()
5555+5656+ return isActive, nil
5757+}
5858+5959+// ActivateSupporter creates or updates a supporter record
6060+func (s *Service) ActivateSupporter(did, handle, email, customerID, subscriptionID string) error {
6161+ existing, err := s.repo.GetByDID(did)
6262+ if err != nil {
6363+ return fmt.Errorf("failed to check existing supporter: %w", err)
6464+ }
6565+6666+ if existing != nil {
6767+ // Update existing record
6868+ existing.Handle = handle
6969+ existing.Email = email
7070+ existing.StripeCustomerID = customerID
7171+ existing.StripeSubscriptionID = subscriptionID
7272+ existing.IsActive = true
7373+ existing.EndDate = nil // Clear any end date
7474+7575+ if err := s.repo.Update(existing); err != nil {
7676+ return fmt.Errorf("failed to update supporter: %w", err)
7777+ }
7878+ } else {
7979+ // Create new record
8080+ supporter := &models.Supporter{
8181+ DID: did,
8282+ Handle: handle,
8383+ Email: email,
8484+ StripeCustomerID: customerID,
8585+ StripeSubscriptionID: subscriptionID,
8686+ PlanType: "supporter",
8787+ IsActive: true,
8888+ StartDate: time.Now(),
8989+ }
9090+9191+ if err := s.repo.Create(supporter); err != nil {
9292+ return fmt.Errorf("failed to create supporter: %w", err)
9393+ }
9494+ }
9595+9696+ // Invalidate cache
9797+ s.mu.Lock()
9898+ delete(s.cache, did)
9999+ s.mu.Unlock()
100100+101101+ return nil
102102+}
103103+104104+// DeactivateSupporter marks a supporter as inactive with grace period
105105+func (s *Service) DeactivateSupporter(subscriptionID string, gracePeriodEnd time.Time) error {
106106+ supporter, err := s.repo.GetBySubscriptionID(subscriptionID)
107107+ if err != nil {
108108+ return fmt.Errorf("failed to get supporter: %w", err)
109109+ }
110110+111111+ if supporter == nil {
112112+ return fmt.Errorf("supporter not found for subscription: %s", subscriptionID)
113113+ }
114114+115115+ supporter.EndDate = &gracePeriodEnd
116116+ // Don't set IsActive to false yet - let it expire naturally
117117+118118+ if err := s.repo.Update(supporter); err != nil {
119119+ return fmt.Errorf("failed to deactivate supporter: %w", err)
120120+ }
121121+122122+ // Invalidate cache
123123+ s.mu.Lock()
124124+ delete(s.cache, supporter.DID)
125125+ s.mu.Unlock()
126126+127127+ return nil
128128+}
129129+130130+// GetSupporter retrieves full supporter details
131131+func (s *Service) GetSupporter(did string) (*models.Supporter, error) {
132132+ return s.repo.GetByDID(did)
133133+}
134134+135135+// GetByCustomerID retrieves supporter by Stripe customer ID
136136+func (s *Service) GetByCustomerID(customerID string) (*models.Supporter, error) {
137137+ return s.repo.GetByCustomerID(customerID)
138138+}
139139+140140+// GetBySubscriptionID retrieves supporter by Stripe subscription ID
141141+func (s *Service) GetBySubscriptionID(subscriptionID string) (*models.Supporter, error) {
142142+ return s.repo.GetBySubscriptionID(subscriptionID)
143143+}
144144+145145+// CountActiveSupporter returns the number of active supporters
146146+func (s *Service) CountActiveSupporter() (int, error) {
147147+ return s.repo.CountActiveSupporter()
148148+}
+32
migrations/0002-supporters.sql
···11+-- Supporter subscription management
22+-- Store supporter status server-side to prevent self-granting
33+44+CREATE TABLE IF NOT EXISTS supporters (
55+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66+ did TEXT NOT NULL UNIQUE,
77+ handle TEXT NOT NULL,
88+ email TEXT,
99+ stripe_customer_id TEXT UNIQUE,
1010+ stripe_subscription_id TEXT UNIQUE,
1111+ plan_type TEXT NOT NULL DEFAULT 'supporter',
1212+ is_active BOOLEAN NOT NULL DEFAULT 1,
1313+ start_date DATETIME NOT NULL,
1414+ end_date DATETIME,
1515+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
1616+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
1717+);
1818+1919+-- Index for fast lookups by DID (primary query pattern)
2020+CREATE INDEX IF NOT EXISTS idx_supporters_did ON supporters(did);
2121+2222+-- Index for fast lookups by customer ID (webhook lookups)
2323+CREATE INDEX IF NOT EXISTS idx_supporters_customer_id ON supporters(stripe_customer_id);
2424+2525+-- Index for fast lookups by subscription ID (webhook lookups)
2626+CREATE INDEX IF NOT EXISTS idx_supporters_subscription_id ON supporters(stripe_subscription_id);
2727+2828+-- Index for email lookups (customer support)
2929+CREATE INDEX IF NOT EXISTS idx_supporters_email ON supporters(email);
3030+3131+-- Index for active supporters (for analytics)
3232+CREATE INDEX IF NOT EXISTS idx_supporters_active ON supporters(is_active) WHERE is_active = 1;