···1+package supporter
2+3+import (
4+ "fmt"
5+ "sync"
6+ "time"
7+8+ "github.com/shindakun/attodo/internal/database"
9+ "github.com/shindakun/attodo/internal/models"
10+)
11+12+// Service handles supporter business logic with caching
13+type Service struct {
14+ repo *database.SupporterRepo
15+ cache map[string]*cachedStatus
16+ mu sync.RWMutex
17+}
18+19+type cachedStatus struct {
20+ IsActive bool
21+ ExpiresAt time.Time
22+}
23+24+// NewService creates a new supporter service
25+func NewService(repo *database.SupporterRepo) *Service {
26+ return &Service{
27+ repo: repo,
28+ cache: make(map[string]*cachedStatus),
29+ }
30+}
31+32+// IsSupporter checks if a user is an active supporter (with caching)
33+func (s *Service) IsSupporter(did string) (bool, error) {
34+ // Check cache first
35+ s.mu.RLock()
36+ if cached, ok := s.cache[did]; ok && time.Now().Before(cached.ExpiresAt) {
37+ s.mu.RUnlock()
38+ return cached.IsActive, nil
39+ }
40+ s.mu.RUnlock()
41+42+ // Query database
43+ isActive, err := s.repo.IsSupporter(did)
44+ if err != nil {
45+ return false, err
46+ }
47+48+ // Cache result for 5 minutes
49+ s.mu.Lock()
50+ s.cache[did] = &cachedStatus{
51+ IsActive: isActive,
52+ ExpiresAt: time.Now().Add(5 * time.Minute),
53+ }
54+ s.mu.Unlock()
55+56+ return isActive, nil
57+}
58+59+// ActivateSupporter creates or updates a supporter record
60+func (s *Service) ActivateSupporter(did, handle, email, customerID, subscriptionID string) error {
61+ existing, err := s.repo.GetByDID(did)
62+ if err != nil {
63+ return fmt.Errorf("failed to check existing supporter: %w", err)
64+ }
65+66+ if existing != nil {
67+ // Update existing record
68+ existing.Handle = handle
69+ existing.Email = email
70+ existing.StripeCustomerID = customerID
71+ existing.StripeSubscriptionID = subscriptionID
72+ existing.IsActive = true
73+ existing.EndDate = nil // Clear any end date
74+75+ if err := s.repo.Update(existing); err != nil {
76+ return fmt.Errorf("failed to update supporter: %w", err)
77+ }
78+ } else {
79+ // Create new record
80+ supporter := &models.Supporter{
81+ DID: did,
82+ Handle: handle,
83+ Email: email,
84+ StripeCustomerID: customerID,
85+ StripeSubscriptionID: subscriptionID,
86+ PlanType: "supporter",
87+ IsActive: true,
88+ StartDate: time.Now(),
89+ }
90+91+ if err := s.repo.Create(supporter); err != nil {
92+ return fmt.Errorf("failed to create supporter: %w", err)
93+ }
94+ }
95+96+ // Invalidate cache
97+ s.mu.Lock()
98+ delete(s.cache, did)
99+ s.mu.Unlock()
100+101+ return nil
102+}
103+104+// DeactivateSupporter marks a supporter as inactive with grace period
105+func (s *Service) DeactivateSupporter(subscriptionID string, gracePeriodEnd time.Time) error {
106+ supporter, err := s.repo.GetBySubscriptionID(subscriptionID)
107+ if err != nil {
108+ return fmt.Errorf("failed to get supporter: %w", err)
109+ }
110+111+ if supporter == nil {
112+ return fmt.Errorf("supporter not found for subscription: %s", subscriptionID)
113+ }
114+115+ supporter.EndDate = &gracePeriodEnd
116+ // Don't set IsActive to false yet - let it expire naturally
117+118+ if err := s.repo.Update(supporter); err != nil {
119+ return fmt.Errorf("failed to deactivate supporter: %w", err)
120+ }
121+122+ // Invalidate cache
123+ s.mu.Lock()
124+ delete(s.cache, supporter.DID)
125+ s.mu.Unlock()
126+127+ return nil
128+}
129+130+// GetSupporter retrieves full supporter details
131+func (s *Service) GetSupporter(did string) (*models.Supporter, error) {
132+ return s.repo.GetByDID(did)
133+}
134+135+// GetByCustomerID retrieves supporter by Stripe customer ID
136+func (s *Service) GetByCustomerID(customerID string) (*models.Supporter, error) {
137+ return s.repo.GetByCustomerID(customerID)
138+}
139+140+// GetBySubscriptionID retrieves supporter by Stripe subscription ID
141+func (s *Service) GetBySubscriptionID(subscriptionID string) (*models.Supporter, error) {
142+ return s.repo.GetBySubscriptionID(subscriptionID)
143+}
144+145+// CountActiveSupporter returns the number of active supporters
146+func (s *Service) CountActiveSupporter() (int, error) {
147+ return s.repo.CountActiveSupporter()
148+}
+32
migrations/0002-supporters.sql
···00000000000000000000000000000000
···1+-- Supporter subscription management
2+-- Store supporter status server-side to prevent self-granting
3+4+CREATE TABLE IF NOT EXISTS supporters (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ did TEXT NOT NULL UNIQUE,
7+ handle TEXT NOT NULL,
8+ email TEXT,
9+ stripe_customer_id TEXT UNIQUE,
10+ stripe_subscription_id TEXT UNIQUE,
11+ plan_type TEXT NOT NULL DEFAULT 'supporter',
12+ is_active BOOLEAN NOT NULL DEFAULT 1,
13+ start_date DATETIME NOT NULL,
14+ end_date DATETIME,
15+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
16+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
17+);
18+19+-- Index for fast lookups by DID (primary query pattern)
20+CREATE INDEX IF NOT EXISTS idx_supporters_did ON supporters(did);
21+22+-- Index for fast lookups by customer ID (webhook lookups)
23+CREATE INDEX IF NOT EXISTS idx_supporters_customer_id ON supporters(stripe_customer_id);
24+25+-- Index for fast lookups by subscription ID (webhook lookups)
26+CREATE INDEX IF NOT EXISTS idx_supporters_subscription_id ON supporters(stripe_subscription_id);
27+28+-- Index for email lookups (customer support)
29+CREATE INDEX IF NOT EXISTS idx_supporters_email ON supporters(email);
30+31+-- Index for active supporters (for analytics)
32+CREATE INDEX IF NOT EXISTS idx_supporters_active ON supporters(is_active) WHERE is_active = 1;