package handlers import ( "context" "encoding/json" "errors" "io" "log" "net/http" "strconv" "git.nixc.us/a250/ss-atlas/internal/accounts" stripego "github.com/stripe/stripe-go/v84" "github.com/stripe/stripe-go/v84/webhook" ) const maxWebhookPayload = 65536 func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookPayload)) if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } sig := r.Header.Get("Stripe-Signature") event, err := webhook.ConstructEvent(body, sig, a.stripe.WebhookSecret()) if err != nil { log.Printf("webhook signature verification failed: %v", err) http.Error(w, "invalid signature", http.StatusUnauthorized) return } switch event.Type { case "checkout.session.completed": a.onCheckoutCompleted(event) case "customer.subscription.deleted": a.onSubscriptionDeleted(event) case "customer.subscription.updated": a.onSubscriptionUpdated(event) case "invoice.paid": a.onInvoicePaid(event) default: log.Printf("unhandled webhook event: %s", event.Type) } w.WriteHeader(http.StatusOK) } // Reconciliation backstop: ensures the account, billing link, and instance exist. func (a *App) onCheckoutCompleted(event stripego.Event) { var sess stripego.CheckoutSession if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { log.Printf("checkout unmarshal error: %v", err) return } email := sess.CustomerDetails.Email customerID := sess.Customer.ID phone := "" domain := "" accountID := int64(0) if sess.Metadata != nil { phone = sess.Metadata["customer_phone"] domain = sess.Metadata["customer_domain"] accountID, _ = strconv.ParseInt(sess.Metadata["account_id"], 10, 64) } log.Printf("webhook: checkout completed email=%s customer=%s account_id=%d", email, customerID, accountID) if a.cfg.MaxSignups > 0 { count, err := a.accounts.CountCustomers(context.Background()) if err == nil && count >= a.cfg.MaxSignups { log.Printf("webhook: signup limit reached (%d), skipping provision for %s", a.cfg.MaxSignups, email) return } } _, _, err := a.accounts.UpsertCheckout(context.Background(), accounts.CheckoutInput{ AccountID: accountID, Email: email, DisplayName: email, Phone: phone, CustomerDomain: domain, StripeCustomerID: customerID, StripeSubscriptionID: subscriptionIDFromSession(&sess), StripeSessionID: sess.ID, StripeEventID: event.ID, }) if err != nil { log.Printf("webhook: account ensure failed: %v", err) } subID := "" if sess.Subscription != nil && sess.Subscription.ID != "" { subID = sess.Subscription.ID } else { var raw struct { Subscription interface{} `json:"subscription"` } if json.Unmarshal(event.Data.Raw, &raw) == nil { if s, ok := raw.Subscription.(string); ok { subID = s } } } if subID != "" { a.maybeScheduleFreeTierCancel(subID) } } func (a *App) onSubscriptionDeleted(event stripego.Event) { var sub stripego.Subscription if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { log.Printf("subscription unmarshal error: %v", err) return } customerID := sub.Customer.ID log.Printf("subscription deleted for customer %s", customerID) _, inst, err := a.accounts.AccountByStripeCustomerID(context.Background(), customerID) if err != nil { log.Printf("could not find account for customer %s: %v", customerID, err) return } if err := a.accounts.MarkSubscriptionStatus(context.Background(), customerID, "cancelled"); err != nil && !errors.Is(err, accounts.ErrNotFound) { log.Printf("subscription status update failed: %v", err) } if err := a.swarm.ArchiveVolumes(inst.StackName, a.cfg.ArchivePath); err != nil { log.Printf("archive failed for %s: %v", inst.StackName, err) } if err := a.swarm.RemoveStack(inst.StackName); err != nil { log.Printf("stack remove failed for %s: %v", inst.StackName, err) } _ = a.accounts.UpdateInstanceState(context.Background(), inst.StackName, "archived", false) log.Printf("deprovisioned stack for customer %s (%s), volumes archived", customerID, inst.Slug) } func (a *App) onSubscriptionUpdated(event stripego.Event) { var sub stripego.Subscription if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { log.Printf("subscription update unmarshal error: %v", err) return } if sub.Status == stripego.SubscriptionStatusCanceled || sub.Status == stripego.SubscriptionStatusUnpaid { if sub.Customer != nil && sub.Customer.ID != "" { _ = a.accounts.MarkSubscriptionStatus(context.Background(), sub.Customer.ID, string(sub.Status)) } log.Printf("subscription %s status=%s, will be cleaned up on deletion", sub.ID, sub.Status) } }