The attodo.app, uhh... app.

goldstar

+1037 -36
+26 -18
internal/config/config.go
··· 7 7 ) 8 8 9 9 type Config struct { 10 - Port string 11 - BaseURL string 12 - ClientName string 13 - Environment string 14 - DBPath string 15 - MigrationsDir string 16 - VAPIDPublicKey string 17 - VAPIDPrivateKey string 18 - VAPIDSubscriber string 10 + Port string 11 + BaseURL string 12 + ClientName string 13 + Environment string 14 + DBPath string 15 + MigrationsDir string 16 + VAPIDPublicKey string 17 + VAPIDPrivateKey string 18 + VAPIDSubscriber string 19 + StripeSecretKey string 20 + StripePublishableKey string 21 + StripeWebhookSecret string 22 + StripePriceID string 19 23 } 20 24 21 25 func Load() (*Config, error) { 22 26 godotenv.Load() // Load .env file if exists 23 27 24 28 cfg := &Config{ 25 - Port: getEnv("PORT", "8181"), 26 - BaseURL: getEnv("BASE_URL", "http://localhost:8181"), 27 - ClientName: getEnv("CLIENT_NAME", "AT Todo App"), 28 - Environment: getEnv("ENVIRONMENT", "production"), 29 - DBPath: getEnv("DB_PATH", "./data/app.db"), 30 - MigrationsDir: getEnv("MIGRATIONS_DIR", "./migrations"), 31 - VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""), 32 - VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""), 33 - VAPIDSubscriber: getEnv("VAPID_SUBSCRIBER", "mailto:admin@attodo.app"), 29 + Port: getEnv("PORT", "8181"), 30 + BaseURL: getEnv("BASE_URL", "http://localhost:8181"), 31 + ClientName: getEnv("CLIENT_NAME", "AT Todo App"), 32 + Environment: getEnv("ENVIRONMENT", "production"), 33 + DBPath: getEnv("DB_PATH", "./data/app.db"), 34 + MigrationsDir: getEnv("MIGRATIONS_DIR", "./migrations"), 35 + VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""), 36 + VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""), 37 + VAPIDSubscriber: getEnv("VAPID_SUBSCRIBER", "mailto:admin@attodo.app"), 38 + StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""), 39 + StripePublishableKey: getEnv("STRIPE_PUBLISHABLE_KEY", ""), 40 + StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""), 41 + StripePriceID: getEnv("STRIPE_PRICE_ID", ""), 34 42 } 35 43 36 44 return cfg, nil
+172
internal/database/supporter_repo.go
··· 1 + package database 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "time" 7 + 8 + "github.com/shindakun/attodo/internal/models" 9 + ) 10 + 11 + // SupporterRepo handles supporter data persistence 12 + type SupporterRepo struct { 13 + db *DB 14 + } 15 + 16 + // NewSupporterRepo creates a new supporter repository 17 + func NewSupporterRepo(db *DB) *SupporterRepo { 18 + return &SupporterRepo{db: db} 19 + } 20 + 21 + // GetByDID retrieves a supporter by their DID 22 + func (r *SupporterRepo) GetByDID(did string) (*models.Supporter, error) { 23 + var s models.Supporter 24 + err := r.db.QueryRow(` 25 + SELECT id, did, handle, email, stripe_customer_id, stripe_subscription_id, 26 + plan_type, is_active, start_date, end_date, created_at, updated_at 27 + FROM supporters 28 + WHERE did = ? 29 + `, did).Scan( 30 + &s.ID, &s.DID, &s.Handle, &s.Email, &s.StripeCustomerID, &s.StripeSubscriptionID, 31 + &s.PlanType, &s.IsActive, &s.StartDate, &s.EndDate, &s.CreatedAt, &s.UpdatedAt, 32 + ) 33 + 34 + if err == sql.ErrNoRows { 35 + return nil, nil // Not a supporter 36 + } 37 + if err != nil { 38 + return nil, fmt.Errorf("failed to get supporter: %w", err) 39 + } 40 + 41 + return &s, nil 42 + } 43 + 44 + // GetByCustomerID retrieves a supporter by Stripe customer ID 45 + func (r *SupporterRepo) GetByCustomerID(customerID string) (*models.Supporter, error) { 46 + var s models.Supporter 47 + err := r.db.QueryRow(` 48 + SELECT id, did, handle, email, stripe_customer_id, stripe_subscription_id, 49 + plan_type, is_active, start_date, end_date, created_at, updated_at 50 + FROM supporters 51 + WHERE stripe_customer_id = ? 52 + `, customerID).Scan( 53 + &s.ID, &s.DID, &s.Handle, &s.Email, &s.StripeCustomerID, &s.StripeSubscriptionID, 54 + &s.PlanType, &s.IsActive, &s.StartDate, &s.EndDate, &s.CreatedAt, &s.UpdatedAt, 55 + ) 56 + 57 + if err == sql.ErrNoRows { 58 + return nil, nil 59 + } 60 + if err != nil { 61 + return nil, fmt.Errorf("failed to get supporter by customer ID: %w", err) 62 + } 63 + 64 + return &s, nil 65 + } 66 + 67 + // GetBySubscriptionID retrieves a supporter by Stripe subscription ID 68 + func (r *SupporterRepo) GetBySubscriptionID(subscriptionID string) (*models.Supporter, error) { 69 + var s models.Supporter 70 + err := r.db.QueryRow(` 71 + SELECT id, did, handle, email, stripe_customer_id, stripe_subscription_id, 72 + plan_type, is_active, start_date, end_date, created_at, updated_at 73 + FROM supporters 74 + WHERE stripe_subscription_id = ? 75 + `, subscriptionID).Scan( 76 + &s.ID, &s.DID, &s.Handle, &s.Email, &s.StripeCustomerID, &s.StripeSubscriptionID, 77 + &s.PlanType, &s.IsActive, &s.StartDate, &s.EndDate, &s.CreatedAt, &s.UpdatedAt, 78 + ) 79 + 80 + if err == sql.ErrNoRows { 81 + return nil, nil 82 + } 83 + if err != nil { 84 + return nil, fmt.Errorf("failed to get supporter by subscription ID: %w", err) 85 + } 86 + 87 + return &s, nil 88 + } 89 + 90 + // Create creates a new supporter record 91 + func (r *SupporterRepo) Create(s *models.Supporter) error { 92 + result, err := r.db.Exec(` 93 + INSERT INTO supporters (did, handle, email, stripe_customer_id, stripe_subscription_id, 94 + plan_type, is_active, start_date, end_date) 95 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 96 + `, s.DID, s.Handle, s.Email, s.StripeCustomerID, s.StripeSubscriptionID, 97 + s.PlanType, s.IsActive, s.StartDate, s.EndDate) 98 + 99 + if err != nil { 100 + return fmt.Errorf("failed to create supporter: %w", err) 101 + } 102 + 103 + id, err := result.LastInsertId() 104 + if err != nil { 105 + return fmt.Errorf("failed to get last insert ID: %w", err) 106 + } 107 + 108 + s.ID = id 109 + s.CreatedAt = time.Now() 110 + s.UpdatedAt = time.Now() 111 + 112 + return nil 113 + } 114 + 115 + // Update updates an existing supporter record 116 + func (r *SupporterRepo) Update(s *models.Supporter) error { 117 + s.UpdatedAt = time.Now() 118 + 119 + _, err := r.db.Exec(` 120 + UPDATE supporters 121 + SET handle = ?, email = ?, stripe_customer_id = ?, stripe_subscription_id = ?, 122 + plan_type = ?, is_active = ?, start_date = ?, end_date = ?, updated_at = ? 123 + WHERE did = ? 124 + `, s.Handle, s.Email, s.StripeCustomerID, s.StripeSubscriptionID, 125 + s.PlanType, s.IsActive, s.StartDate, s.EndDate, s.UpdatedAt, s.DID) 126 + 127 + if err != nil { 128 + return fmt.Errorf("failed to update supporter: %w", err) 129 + } 130 + 131 + return nil 132 + } 133 + 134 + // Delete removes a supporter record 135 + func (r *SupporterRepo) Delete(did string) error { 136 + _, err := r.db.Exec(`DELETE FROM supporters WHERE did = ?`, did) 137 + if err != nil { 138 + return fmt.Errorf("failed to delete supporter: %w", err) 139 + } 140 + return nil 141 + } 142 + 143 + // IsSupporter checks if a user is an active supporter 144 + // Returns true if the user is active and within their subscription period 145 + func (r *SupporterRepo) IsSupporter(did string) (bool, error) { 146 + var isActive bool 147 + err := r.db.QueryRow(` 148 + SELECT is_active 149 + FROM supporters 150 + WHERE did = ? AND (end_date IS NULL OR end_date > datetime('now')) 151 + `, did).Scan(&isActive) 152 + 153 + if err == sql.ErrNoRows { 154 + return false, nil // Not a supporter 155 + } 156 + if err != nil { 157 + return false, fmt.Errorf("failed to check supporter status: %w", err) 158 + } 159 + 160 + return isActive, nil 161 + } 162 + 163 + // CountActiveSupporter returns the number of active supporters 164 + func (r *SupporterRepo) CountActiveSupporter() (int, error) { 165 + var count int 166 + err := r.db.QueryRow(` 167 + SELECT COUNT(*) FROM supporters 168 + WHERE is_active = 1 AND (end_date IS NULL OR end_date > datetime('now')) 169 + `).Scan(&count) 170 + 171 + return count, err 172 + }
+288
internal/handlers/supporter.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "log" 8 + "net/http" 9 + "time" 10 + 11 + "github.com/shindakun/attodo/internal/session" 12 + stripeClient "github.com/shindakun/attodo/internal/stripe" 13 + "github.com/shindakun/attodo/internal/supporter" 14 + "github.com/stripe/stripe-go/v84" 15 + ) 16 + 17 + // SupporterHandler handles supporter-related HTTP requests 18 + type SupporterHandler struct { 19 + service *supporter.Service 20 + stripeClient *stripeClient.Client 21 + baseURL string 22 + } 23 + 24 + // NewSupporterHandler creates a new supporter handler 25 + func NewSupporterHandler(service *supporter.Service, stripe *stripeClient.Client, baseURL string) *SupporterHandler { 26 + return &SupporterHandler{ 27 + service: service, 28 + stripeClient: stripe, 29 + baseURL: baseURL, 30 + } 31 + } 32 + 33 + // HandleGetStatus returns the current supporter status for the logged-in user 34 + // GET /supporter/status 35 + func (h *SupporterHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) { 36 + sess, ok := session.GetSession(r) 37 + if !ok || sess == nil { 38 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 39 + return 40 + } 41 + 42 + isSupporter, err := h.service.IsSupporter(sess.DID) 43 + if err != nil { 44 + log.Printf("Failed to check supporter status: %v", err) 45 + http.Error(w, "Internal server error", http.StatusInternalServerError) 46 + return 47 + } 48 + 49 + w.Header().Set("Content-Type", "application/json") 50 + json.NewEncoder(w).Encode(map[string]interface{}{ 51 + "isSupporter": isSupporter, 52 + }) 53 + } 54 + 55 + // HandleCreateCheckoutSession creates a Stripe Checkout session 56 + // GET /supporter/checkout 57 + func (h *SupporterHandler) HandleCreateCheckoutSession(w http.ResponseWriter, r *http.Request) { 58 + sess, ok := session.GetSession(r) 59 + if !ok || sess == nil { 60 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 61 + return 62 + } 63 + 64 + // Build success and cancel URLs 65 + successURL := fmt.Sprintf("%s/app?supporter=success", h.baseURL) 66 + cancelURL := fmt.Sprintf("%s/app?supporter=cancelled", h.baseURL) 67 + 68 + // Note: Handle and email will be extracted from profile or set in webhook 69 + // The DID is the primary identifier we need 70 + 71 + // Create Stripe checkout session 72 + checkoutSession, err := h.stripeClient.CreateCheckoutSession( 73 + sess.DID, 74 + "", // handle - will be updated from webhook metadata if available 75 + "", // email - will be updated from webhook metadata if available 76 + successURL, 77 + cancelURL, 78 + ) 79 + if err != nil { 80 + log.Printf("Failed to create checkout session: %v", err) 81 + http.Error(w, "Failed to create checkout session", http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + // Return the checkout session URL 86 + w.Header().Set("Content-Type", "application/json") 87 + json.NewEncoder(w).Encode(map[string]interface{}{ 88 + "url": checkoutSession.URL, 89 + }) 90 + } 91 + 92 + // HandleCreatePortalSession creates a Stripe Customer Portal session 93 + // GET /supporter/portal 94 + func (h *SupporterHandler) HandleCreatePortalSession(w http.ResponseWriter, r *http.Request) { 95 + sess, ok := session.GetSession(r) 96 + if !ok || sess == nil { 97 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 98 + return 99 + } 100 + 101 + // Get supporter record to find Stripe customer ID 102 + supporter, err := h.service.GetSupporter(sess.DID) 103 + if err != nil { 104 + log.Printf("Failed to get supporter: %v", err) 105 + http.Error(w, "Internal server error", http.StatusInternalServerError) 106 + return 107 + } 108 + 109 + if supporter == nil || supporter.StripeCustomerID == "" { 110 + http.Error(w, "No subscription found", http.StatusNotFound) 111 + return 112 + } 113 + 114 + // Create portal session 115 + returnURL := fmt.Sprintf("%s/app", h.baseURL) 116 + portalSession, err := h.stripeClient.CreateCustomerPortalSession(supporter.StripeCustomerID, returnURL) 117 + if err != nil { 118 + log.Printf("Failed to create portal session: %v", err) 119 + http.Error(w, "Failed to create portal session", http.StatusInternalServerError) 120 + return 121 + } 122 + 123 + // Return the portal session URL 124 + w.Header().Set("Content-Type", "application/json") 125 + json.NewEncoder(w).Encode(map[string]interface{}{ 126 + "url": portalSession.URL, 127 + }) 128 + } 129 + 130 + // HandleStripeWebhook handles Stripe webhook events 131 + // POST /supporter/webhook 132 + func (h *SupporterHandler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) { 133 + payload, err := io.ReadAll(r.Body) 134 + if err != nil { 135 + log.Printf("Error reading webhook request body: %v", err) 136 + http.Error(w, "Error reading request body", http.StatusBadRequest) 137 + return 138 + } 139 + 140 + // Verify webhook signature 141 + event, err := h.stripeClient.ConstructWebhookEvent(payload, r.Header.Get("Stripe-Signature")) 142 + if err != nil { 143 + log.Printf("Webhook signature verification failed: %v", err) 144 + http.Error(w, "Webhook signature verification failed", http.StatusBadRequest) 145 + return 146 + } 147 + 148 + log.Printf("[Stripe Webhook] Received event: %s", event.Type) 149 + 150 + // Handle different event types 151 + switch event.Type { 152 + case "checkout.session.completed": 153 + h.handleCheckoutCompleted(event) 154 + case "customer.subscription.created": 155 + h.handleSubscriptionCreated(event) 156 + case "customer.subscription.updated": 157 + h.handleSubscriptionUpdated(event) 158 + case "customer.subscription.deleted": 159 + h.handleSubscriptionDeleted(event) 160 + case "invoice.payment_succeeded": 161 + h.handlePaymentSucceeded(event) 162 + case "invoice.payment_failed": 163 + h.handlePaymentFailed(event) 164 + default: 165 + log.Printf("[Stripe Webhook] Unhandled event type: %s", event.Type) 166 + } 167 + 168 + w.WriteHeader(http.StatusOK) 169 + } 170 + 171 + // handleCheckoutCompleted processes checkout.session.completed event 172 + func (h *SupporterHandler) handleCheckoutCompleted(event stripe.Event) { 173 + var session stripe.CheckoutSession 174 + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { 175 + log.Printf("[Stripe Webhook] Error parsing checkout session: %v", err) 176 + return 177 + } 178 + 179 + // Get user info from session metadata 180 + did := session.Metadata["did"] 181 + handle := session.Metadata["handle"] 182 + email := session.Metadata["email"] 183 + 184 + if did == "" { 185 + log.Printf("[Stripe Webhook] Missing DID in checkout session metadata") 186 + return 187 + } 188 + 189 + // Extract customer and subscription IDs 190 + customerID := "" 191 + if session.Customer != nil { 192 + customerID = session.Customer.ID 193 + } 194 + 195 + subscriptionID := "" 196 + if session.Subscription != nil { 197 + subscriptionID = session.Subscription.ID 198 + } 199 + 200 + // Activate supporter status 201 + err := h.service.ActivateSupporter(did, handle, email, customerID, subscriptionID) 202 + if err != nil { 203 + log.Printf("[Stripe Webhook] Error activating supporter: %v", err) 204 + return 205 + } 206 + 207 + log.Printf("[Stripe Webhook] Activated supporter: %s (%s)", handle, did) 208 + } 209 + 210 + // handleSubscriptionCreated processes customer.subscription.created event 211 + func (h *SupporterHandler) handleSubscriptionCreated(event stripe.Event) { 212 + var subscription stripe.Subscription 213 + if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil { 214 + log.Printf("[Stripe Webhook] Error parsing subscription: %v", err) 215 + return 216 + } 217 + 218 + log.Printf("[Stripe Webhook] Subscription created: %s for customer %s", subscription.ID, subscription.Customer.ID) 219 + // Usually handled by checkout.session.completed, but log for tracking 220 + } 221 + 222 + // handleSubscriptionUpdated processes customer.subscription.updated event 223 + func (h *SupporterHandler) handleSubscriptionUpdated(event stripe.Event) { 224 + var subscription stripe.Subscription 225 + if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil { 226 + log.Printf("[Stripe Webhook] Error parsing subscription: %v", err) 227 + return 228 + } 229 + 230 + log.Printf("[Stripe Webhook] Subscription updated: %s (status: %s)", subscription.ID, subscription.Status) 231 + 232 + // If subscription is canceled or past_due, we might want to update supporter status 233 + if subscription.Status == stripe.SubscriptionStatusCanceled || 234 + subscription.Status == stripe.SubscriptionStatusUnpaid { 235 + // Handle subscription end with grace period 236 + // In Stripe API v84+, period end is on subscription items, not the subscription 237 + var endDate time.Time 238 + if len(subscription.Items.Data) > 0 { 239 + endDate = time.Unix(subscription.Items.Data[0].CurrentPeriodEnd, 0) 240 + } else { 241 + // Fallback to now if no items found 242 + endDate = time.Now() 243 + } 244 + if err := h.service.DeactivateSupporter(subscription.ID, endDate); err != nil { 245 + log.Printf("[Stripe Webhook] Error deactivating supporter: %v", err) 246 + } 247 + } 248 + } 249 + 250 + // handleSubscriptionDeleted processes customer.subscription.deleted event 251 + func (h *SupporterHandler) handleSubscriptionDeleted(event stripe.Event) { 252 + var subscription stripe.Subscription 253 + if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil { 254 + log.Printf("[Stripe Webhook] Error parsing subscription: %v", err) 255 + return 256 + } 257 + 258 + log.Printf("[Stripe Webhook] Subscription deleted: %s", subscription.ID) 259 + 260 + // Deactivate supporter immediately (already past grace period) 261 + if err := h.service.DeactivateSupporter(subscription.ID, time.Now()); err != nil { 262 + log.Printf("[Stripe Webhook] Error deactivating supporter: %v", err) 263 + } 264 + } 265 + 266 + // handlePaymentSucceeded processes invoice.payment_succeeded event 267 + func (h *SupporterHandler) handlePaymentSucceeded(event stripe.Event) { 268 + var invoice stripe.Invoice 269 + if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil { 270 + log.Printf("[Stripe Webhook] Error parsing invoice: %v", err) 271 + return 272 + } 273 + 274 + log.Printf("[Stripe Webhook] Payment succeeded: %s for customer %s (amount: %d %s)", 275 + invoice.ID, invoice.Customer.ID, invoice.AmountPaid, invoice.Currency) 276 + } 277 + 278 + // handlePaymentFailed processes invoice.payment_failed event 279 + func (h *SupporterHandler) handlePaymentFailed(event stripe.Event) { 280 + var invoice stripe.Invoice 281 + if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil { 282 + log.Printf("[Stripe Webhook] Error parsing invoice: %v", err) 283 + return 284 + } 285 + 286 + log.Printf("[Stripe Webhook] Payment failed: %s for customer %s", invoice.ID, invoice.Customer.ID) 287 + // Stripe will retry payments automatically, so we just log this for monitoring 288 + }
+19
internal/models/supporter.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // Supporter represents a user who has subscribed to support AT Todo 6 + type Supporter struct { 7 + ID int64 `db:"id" json:"id"` 8 + DID string `db:"did" json:"did"` 9 + Handle string `db:"handle" json:"handle"` 10 + Email string `db:"email" json:"email,omitempty"` 11 + StripeCustomerID string `db:"stripe_customer_id" json:"stripeCustomerId,omitempty"` 12 + StripeSubscriptionID string `db:"stripe_subscription_id" json:"stripeSubscriptionId,omitempty"` 13 + PlanType string `db:"plan_type" json:"planType"` 14 + IsActive bool `db:"is_active" json:"isActive"` 15 + StartDate time.Time `db:"start_date" json:"startDate"` 16 + EndDate *time.Time `db:"end_date" json:"endDate,omitempty"` 17 + CreatedAt time.Time `db:"created_at" json:"createdAt"` 18 + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` 19 + }
+93
internal/stripe/client.go
··· 1 + package stripe 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/stripe/stripe-go/v84" 7 + billingSession "github.com/stripe/stripe-go/v84/billingportal/session" 8 + checkoutSession "github.com/stripe/stripe-go/v84/checkout/session" 9 + "github.com/stripe/stripe-go/v84/webhook" 10 + ) 11 + 12 + // Client wraps Stripe API client with our configuration 13 + type Client struct { 14 + webhookSecret string 15 + priceID string 16 + } 17 + 18 + // NewClient creates a new Stripe client 19 + // secretKey: Stripe secret key (sk_test_... or sk_live_...) 20 + // webhookSecret: Stripe webhook signing secret (whsec_...) 21 + // priceID: Stripe price ID for the supporter plan (price_...) 22 + func NewClient(secretKey, webhookSecret, priceID string) *Client { 23 + stripe.Key = secretKey 24 + return &Client{ 25 + webhookSecret: webhookSecret, 26 + priceID: priceID, 27 + } 28 + } 29 + 30 + // CreateCheckoutSession creates a new Stripe Checkout session for subscription 31 + func (c *Client) CreateCheckoutSession(did, handle, email, successURL, cancelURL string) (*stripe.CheckoutSession, error) { 32 + params := &stripe.CheckoutSessionParams{ 33 + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), 34 + LineItems: []*stripe.CheckoutSessionLineItemParams{ 35 + { 36 + Price: stripe.String(c.priceID), 37 + Quantity: stripe.Int64(1), 38 + }, 39 + }, 40 + SuccessURL: stripe.String(successURL), 41 + CancelURL: stripe.String(cancelURL), 42 + BrandingSettings: &stripe.CheckoutSessionBrandingSettingsParams{ 43 + BackgroundColor: stripe.String("#1a1a1a"), 44 + ButtonColor: stripe.String("#5469d4"), 45 + BorderStyle: stripe.String("rounded"), 46 + }, 47 + } 48 + 49 + // Add customer email if provided 50 + if email != "" { 51 + params.CustomerEmail = stripe.String(email) 52 + } 53 + 54 + // Add metadata for webhook processing 55 + params.AddMetadata("did", did) 56 + params.AddMetadata("handle", handle) 57 + if email != "" { 58 + params.AddMetadata("email", email) 59 + } 60 + 61 + sess, err := checkoutSession.New(params) 62 + if err != nil { 63 + return nil, fmt.Errorf("failed to create checkout session: %w", err) 64 + } 65 + 66 + return sess, nil 67 + } 68 + 69 + // CreateCustomerPortalSession creates a Stripe Customer Portal session 70 + // This allows users to manage their subscription (cancel, update payment, etc.) 71 + func (c *Client) CreateCustomerPortalSession(customerID, returnURL string) (*stripe.BillingPortalSession, error) { 72 + params := &stripe.BillingPortalSessionParams{ 73 + Customer: stripe.String(customerID), 74 + ReturnURL: stripe.String(returnURL), 75 + } 76 + 77 + sess, err := billingSession.New(params) 78 + if err != nil { 79 + return nil, fmt.Errorf("failed to create portal session: %w", err) 80 + } 81 + 82 + return sess, nil 83 + } 84 + 85 + // ConstructWebhookEvent verifies and constructs a webhook event from the request 86 + func (c *Client) ConstructWebhookEvent(payload []byte, signature string) (stripe.Event, error) { 87 + event, err := webhook.ConstructEvent(payload, signature, c.webhookSecret) 88 + if err != nil { 89 + return stripe.Event{}, fmt.Errorf("failed to verify webhook signature: %w", err) 90 + } 91 + 92 + return event, nil 93 + }
+148
internal/supporter/service.go
··· 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
··· 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;
+2
templates/dashboard.html
··· 858 858 <li><a href="/docs">Docs</a></li> 859 859 <li><a href="#" onclick="showSettings(); return false;">Settings</a></li> 860 860 <li><a href="/logout">Logout</a></li> 861 + <li id="supporter-badge" style="display: none;" title="Supporter">⭐</li> 861 862 </ul> 862 863 </nav> 863 864 </header> ··· 1002 1003 <h2>Settings</h2> 1003 1004 </header> 1004 1005 {{template "notification-settings.html"}} 1006 + {{template "supporter-settings"}} 1005 1007 </article> 1006 1008 </dialog> 1007 1009 </main>
+99 -18
templates/partials/notification-settings.html
··· 143 143 statusEl.textContent = 'Enabled ✓'; 144 144 statusEl.style.color = 'var(--pico-primary)'; 145 145 prefsEl.style.display = 'block'; 146 + 147 + // Ensure we have a push subscription (idempotent) 148 + await subscribeToPush(); 146 149 } else if (permission === 'denied') { 147 150 statusEl.textContent = 'Blocked (check browser settings)'; 148 151 statusEl.style.color = 'var(--pico-del-color)'; ··· 191 194 } catch (error) { 192 195 console.error('Failed to save initial settings:', error); 193 196 } 197 + 198 + // Subscribe to push notifications 199 + await subscribeToPush(); 194 200 195 201 // Register periodic sync for background notifications 196 202 await registerPeriodicSync(); ··· 286 292 } 287 293 288 294 try { 289 - if (!('serviceWorker' in navigator)) { 290 - showToast('Service workers not supported', 'error'); 291 - return; 295 + // Call backend to send real push notification 296 + const response = await fetch('/app/push/test', { 297 + method: 'POST', 298 + headers: { 299 + 'Content-Type': 'application/json' 300 + } 301 + }); 302 + 303 + if (!response.ok) { 304 + const error = await response.text(); 305 + throw new Error(error || 'Failed to send test notification'); 306 + } 307 + 308 + const result = await response.json(); 309 + 310 + if (result.success) { 311 + showToast(`Test notification sent to ${result.sent} device(s)!`, 'success'); 312 + 313 + // Close the settings modal so user can see the notification 314 + if (typeof closeSettings === 'function') { 315 + closeSettings(); 316 + } 317 + } else { 318 + showToast('Failed to send test notification', 'error'); 292 319 } 320 + } catch (error) { 321 + console.error('Test notification error:', error); 322 + showToast('Error: ' + error.message, 'error'); 323 + } 324 + } 325 + 326 + // Subscribe to push notifications 327 + async function subscribeToPush() { 328 + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { 329 + console.warn('Push notifications not supported'); 330 + return; 331 + } 293 332 333 + try { 334 + console.log('[Push] Waiting for service worker to be ready...'); 294 335 const registration = await navigator.serviceWorker.ready; 295 - if (!registration) { 296 - showToast('Service worker not registered', 'error'); 297 - return; 336 + console.log('[Push] Service worker ready'); 337 + 338 + // Check if already subscribed 339 + let subscription = await registration.pushManager.getSubscription(); 340 + console.log('[Push] Existing subscription:', subscription ? 'found' : 'not found'); 341 + 342 + if (!subscription) { 343 + // Get VAPID public key from server 344 + console.log('[Push] Fetching VAPID public key...'); 345 + const response = await fetch('/app/push/vapid-key'); 346 + if (!response.ok) { 347 + const errorText = await response.text(); 348 + throw new Error(`Failed to get VAPID public key: ${response.status} ${errorText}`); 349 + } 350 + const { publicKey } = await response.json(); 351 + console.log('[Push] Got VAPID public key:', publicKey.substring(0, 20) + '...'); 352 + 353 + // Subscribe to push notifications 354 + console.log('[Push] Subscribing to push manager...'); 355 + subscription = await registration.pushManager.subscribe({ 356 + userVisibleOnly: true, 357 + applicationServerKey: urlBase64ToUint8Array(publicKey) 358 + }); 359 + console.log('[Push] Push manager subscription created'); 298 360 } 299 361 300 - await registration.showNotification('AT Todo Test', { 301 - body: 'Notifications are working!', 302 - icon: '/static/icon-192.png', 303 - badge: '/static/icon-192.png', 304 - tag: 'test', 305 - requireInteraction: false 362 + // Send subscription to backend 363 + console.log('[Push] Registering subscription with backend...'); 364 + const subscribeResponse = await fetch('/app/push/subscribe', { 365 + method: 'POST', 366 + headers: { 367 + 'Content-Type': 'application/json' 368 + }, 369 + body: JSON.stringify(subscription.toJSON()) 306 370 }); 307 371 308 - // Close the settings modal so user can see the notification 309 - if (typeof closeSettings === 'function') { 310 - closeSettings(); 372 + if (!subscribeResponse.ok) { 373 + const errorText = await subscribeResponse.text(); 374 + throw new Error(`Failed to register push subscription: ${subscribeResponse.status} ${errorText}`); 311 375 } 312 376 313 - showToast('Test notification sent!', 'success'); 377 + console.log('[Push] Subscription registered successfully'); 314 378 } catch (error) { 315 - console.error('Test notification error:', error); 316 - showToast('Error: ' + error.message, 'error'); 379 + console.error('[Push] Failed to subscribe:', error); 380 + // Don't show toast on page load - only when user explicitly enables 381 + // showToast('Failed to set up push notifications', 'error'); 382 + } 383 + } 384 + 385 + // Convert base64 VAPID key to Uint8Array 386 + function urlBase64ToUint8Array(base64String) { 387 + const padding = '='.repeat((4 - base64String.length % 4) % 4); 388 + const base64 = (base64String + padding) 389 + .replace(/\-/g, '+') 390 + .replace(/_/g, '/'); 391 + 392 + const rawData = window.atob(base64); 393 + const outputArray = new Uint8Array(rawData.length); 394 + 395 + for (let i = 0; i < rawData.length; ++i) { 396 + outputArray[i] = rawData.charCodeAt(i); 317 397 } 398 + return outputArray; 318 399 } 319 400 320 401 // Register periodic background sync for notifications
+158
templates/partials/supporter-settings.html
··· 1 + {{define "supporter-settings"}} 2 + <script> 3 + let supporterStatus = { 4 + isSupporter: false, 5 + loading: true 6 + }; 7 + 8 + // Load supporter status on page load 9 + async function loadSupporterStatus() { 10 + try { 11 + const response = await fetch('/supporter/status', { 12 + credentials: 'include' 13 + }); 14 + 15 + if (response.ok) { 16 + const data = await response.json(); 17 + supporterStatus = { 18 + isSupporter: data.isSupporter, 19 + loading: false 20 + }; 21 + 22 + updateSupporterUI(); 23 + } else { 24 + supporterStatus.loading = false; 25 + updateSupporterUI(); 26 + } 27 + } catch (error) { 28 + console.error('Failed to load supporter status:', error); 29 + supporterStatus.loading = false; 30 + updateSupporterUI(); 31 + } 32 + } 33 + 34 + // Update UI based on supporter status 35 + function updateSupporterUI() { 36 + // Update badge in header 37 + const badge = document.getElementById('supporter-badge'); 38 + if (badge) { 39 + badge.style.display = supporterStatus.isSupporter ? 'inline' : 'none'; 40 + } 41 + 42 + // Update settings panel 43 + const activeSection = document.getElementById('supporter-active'); 44 + const inactiveSection = document.getElementById('supporter-inactive'); 45 + 46 + if (activeSection && inactiveSection) { 47 + if (supporterStatus.loading) { 48 + activeSection.style.display = 'none'; 49 + inactiveSection.style.display = 'none'; 50 + } else if (supporterStatus.isSupporter) { 51 + activeSection.style.display = 'block'; 52 + inactiveSection.style.display = 'none'; 53 + } else { 54 + activeSection.style.display = 'none'; 55 + inactiveSection.style.display = 'block'; 56 + } 57 + } 58 + } 59 + 60 + // Start checkout flow 61 + async function startCheckout() { 62 + try { 63 + const response = await fetch('/supporter/checkout', { 64 + method: 'GET', 65 + credentials: 'include' 66 + }); 67 + 68 + if (response.ok) { 69 + const data = await response.json(); 70 + // Redirect to Stripe checkout 71 + window.location.href = data.url; 72 + } else { 73 + const error = await response.text(); 74 + showToast('Failed to start checkout: ' + error, 'error'); 75 + } 76 + } catch (error) { 77 + console.error('Checkout error:', error); 78 + showToast('An error occurred. Please try again.', 'error'); 79 + } 80 + } 81 + 82 + // Open customer portal 83 + async function openPortal() { 84 + try { 85 + const response = await fetch('/supporter/portal', { 86 + method: 'GET', 87 + credentials: 'include' 88 + }); 89 + 90 + if (response.ok) { 91 + const data = await response.json(); 92 + // Redirect to Stripe customer portal 93 + window.location.href = data.url; 94 + } else { 95 + const error = await response.text(); 96 + showToast('Failed to open portal: ' + error, 'error'); 97 + } 98 + } catch (error) { 99 + console.error('Portal error:', error); 100 + showToast('An error occurred. Please try again.', 'error'); 101 + } 102 + } 103 + 104 + // Handle success/cancelled redirects 105 + function handleSupporterRedirect() { 106 + const urlParams = new URLSearchParams(window.location.search); 107 + if (urlParams.get('supporter') === 'success') { 108 + showToast('Thank you for becoming a supporter! 🎉', 'success'); 109 + // Reload status after a short delay 110 + setTimeout(() => { 111 + loadSupporterStatus(); 112 + // Clear the URL parameter 113 + const newUrl = window.location.origin + window.location.pathname; 114 + window.history.replaceState({}, document.title, newUrl); 115 + }, 2000); 116 + } else if (urlParams.get('supporter') === 'cancelled') { 117 + showToast('Checkout cancelled. You can become a supporter anytime!', 'info'); 118 + // Clear the URL parameter 119 + const newUrl = window.location.origin + window.location.pathname; 120 + window.history.replaceState({}, document.title, newUrl); 121 + } 122 + } 123 + 124 + // Initialize on page load 125 + document.addEventListener('DOMContentLoaded', () => { 126 + loadSupporterStatus(); 127 + handleSupporterRedirect(); 128 + }); 129 + </script> 130 + 131 + <!-- Supporter Settings Section --> 132 + <div id="supporter-settings" style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--pico-muted-border-color);"> 133 + <h3>Supporter Status</h3> 134 + 135 + <!-- Not a supporter --> 136 + <div id="supporter-inactive" style="display: none;"> 137 + <p>Support AT Todo development and get a gold star badge!</p> 138 + <ul> 139 + <li>⭐ Gold star in the UI</li> 140 + <li>💙 Support development</li> 141 + <li>💙 Help cover server costs</li> 142 + <li>💙 Help keep it free for everyone</li> 143 + </ul> 144 + <button onclick="startCheckout()" class="secondary"> 145 + Become a Supporter ($24/year) 146 + </button> 147 + </div> 148 + 149 + <!-- Active supporter --> 150 + <div id="supporter-active" style="display: none;"> 151 + <p style="font-size: 1.2rem; margin-bottom: 1rem;">✨ Thank you for being a supporter! ✨</p> 152 + <p>Your subscription helps keep AT Todo free for everyone.</p> 153 + <button onclick="openPortal()" class="secondary" style="margin-top: 1rem;"> 154 + Manage Subscription 155 + </button> 156 + </div> 157 + </div> 158 + {{end}}