package billing import ( "context" "fmt" "io" "log/slog" "net/http" "strconv" "time" ) type MemberSyncer interface { SyncMember(ctx context.Context, tenantID, userID, email, name, tier, status string, expiresAt *time.Time) error } type WebhookHandler struct { store *Store lemon *LemonClient memberSyncer MemberSyncer } func NewWebhookHandler(store *Store, lemonClient *LemonClient, syncer MemberSyncer) *WebhookHandler { return &WebhookHandler{ store: store, lemon: lemonClient, memberSyncer: syncer, } } func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { slog.Error("failed to read webhook body", "error", err) http.Error(w, "Failed to read body", http.StatusBadRequest) return } signature := r.Header.Get("X-Signature") if !h.lemon.VerifyWebhook(body, signature) { slog.Warn("invalid webhook signature") http.Error(w, "Invalid signature", http.StatusUnauthorized) return } event, err := h.lemon.ParseWebhookEvent(body) if err != nil { slog.Error("failed to parse webhook event", "error", err) http.Error(w, "Failed to parse event", http.StatusBadRequest) return } ctx := r.Context() switch event.Meta.EventName { case "subscription_created": err = h.handleSubscriptionCreated(ctx, event) case "subscription_updated": err = h.handleSubscriptionUpdated(ctx, event) case "subscription_cancelled": err = h.handleSubscriptionCancelled(ctx, event) case "subscription_payment_success": err = h.handleSubscriptionPayment(ctx, event) case "order_created": err = h.handleOrderCreated(ctx, event) default: slog.Debug("unhandled webhook event", "event", event.Meta.EventName) } if err != nil { slog.Error("webhook handler error", "event", event.Meta.EventName, "error", err) http.Error(w, "Handler error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } func (h *WebhookHandler) handleSubscriptionCreated(ctx context.Context, event *WebhookEvent) error { data, err := event.GetSubscriptionData() if err != nil { return fmt.Errorf("parse subscription data: %w", err) } tenantID := event.Meta.CustomData["tenant_id"] userID := event.Meta.CustomData["user_id"] tierID := event.Meta.CustomData["tier_id"] if tenantID == "" { return fmt.Errorf("missing tenant_id in custom data") } sub := &Subscription{ TenantID: tenantID, UserID: userID, TierID: tierID, TierName: data.Attributes.VariantName, Status: normalizeStatus(data.Attributes.Status), LemonSubscriptionID: data.ID, LemonCustomerID: strconv.Itoa(data.Attributes.CustomerID), AmountCents: data.Attributes.FirstSubscriptionItem.Price, } if data.Attributes.RenewsAt != "" { if t, err := time.Parse(time.RFC3339, data.Attributes.RenewsAt); err == nil { sub.CurrentPeriodEnd = t } } sub.CurrentPeriodStart = time.Now() if err := h.store.CreateSubscription(ctx, sub); err != nil { return fmt.Errorf("create subscription: %w", err) } if h.memberSyncer != nil && userID != "" { user, _ := h.store.GetUserByID(ctx, userID) email, name := "", "" if user != nil { email, name = user.Email, user.Name } if err := h.memberSyncer.SyncMember(ctx, tenantID, userID, email, name, sub.TierName, "active", &sub.CurrentPeriodEnd); err != nil { slog.Error("sync member failed", "tenant_id", tenantID, "user_id", userID, "error", err) } } slog.Info("subscription created", "tenant_id", tenantID, "lemon_id", data.ID, "tier", data.Attributes.VariantName, ) return nil } func (h *WebhookHandler) handleSubscriptionUpdated(ctx context.Context, event *WebhookEvent) error { data, err := event.GetSubscriptionData() if err != nil { return fmt.Errorf("parse subscription data: %w", err) } var renewsAt *time.Time if data.Attributes.RenewsAt != "" { if t, err := time.Parse(time.RFC3339, data.Attributes.RenewsAt); err == nil { renewsAt = &t } } status := normalizeStatus(data.Attributes.Status) if err := h.store.UpdateSubscriptionStatus(ctx, data.ID, status, renewsAt); err != nil { return fmt.Errorf("update subscription: %w", err) } if h.memberSyncer != nil { sub, _ := h.store.GetSubscriptionByLemonID(ctx, data.ID) if sub != nil && sub.UserID != "" { user, _ := h.store.GetUserByID(ctx, sub.UserID) email, name := "", "" if user != nil { email, name = user.Email, user.Name } if err := h.memberSyncer.SyncMember(ctx, sub.TenantID, sub.UserID, email, name, sub.TierName, status, renewsAt); err != nil { slog.Error("sync member failed", "tenant_id", sub.TenantID, "user_id", sub.UserID, "error", err) } } } slog.Info("subscription updated", "lemon_id", data.ID, "status", status) return nil } func (h *WebhookHandler) handleSubscriptionCancelled(ctx context.Context, event *WebhookEvent) error { data, err := event.GetSubscriptionData() if err != nil { return fmt.Errorf("parse subscription data: %w", err) } sub, _ := h.store.GetSubscriptionByLemonID(ctx, data.ID) if err := h.store.CancelSubscription(ctx, data.ID); err != nil { return fmt.Errorf("cancel subscription: %w", err) } if h.memberSyncer != nil && sub != nil && sub.UserID != "" { user, _ := h.store.GetUserByID(ctx, sub.UserID) email, name := "", "" if user != nil { email, name = user.Email, user.Name } if err := h.memberSyncer.SyncMember(ctx, sub.TenantID, sub.UserID, email, name, sub.TierName, "cancelled", nil); err != nil { slog.Error("sync member failed", "tenant_id", sub.TenantID, "user_id", sub.UserID, "error", err) } } slog.Info("subscription cancelled", "lemon_id", data.ID) return nil } func (h *WebhookHandler) handleSubscriptionPayment(ctx context.Context, event *WebhookEvent) error { data, err := event.GetSubscriptionData() if err != nil { return fmt.Errorf("parse subscription data: %w", err) } sub, err := h.store.GetSubscriptionByLemonID(ctx, data.ID) if err != nil { return fmt.Errorf("get subscription: %w", err) } if sub == nil { return fmt.Errorf("subscription not found: %s", data.ID) } grossCents := data.Attributes.FirstSubscriptionItem.Price platformFeeCents := grossCents * 5 / 100 processorFeeCents := grossCents * 5 / 100 netCents := grossCents - platformFeeCents - processorFeeCents description := fmt.Sprintf("%s subscription - %s", sub.TierName, time.Now().Format("January 2006")) if err := h.store.AddEarnings(ctx, sub.TenantID, "subscription_payment", sub.ID, description, grossCents, platformFeeCents, processorFeeCents, netCents); err != nil { return fmt.Errorf("add earnings: %w", err) } slog.Info("subscription payment recorded", "tenant_id", sub.TenantID, "gross_cents", grossCents, "net_cents", netCents, ) return nil } func (h *WebhookHandler) handleOrderCreated(ctx context.Context, event *WebhookEvent) error { data, err := event.GetOrderData() if err != nil { return fmt.Errorf("parse order data: %w", err) } tenantID := event.Meta.CustomData["tenant_id"] userID := event.Meta.CustomData["user_id"] message := event.Meta.CustomData["message"] if tenantID == "" { return fmt.Errorf("missing tenant_id in custom data") } if event.Meta.CustomData["type"] != "donation" { return nil } donation := &Donation{ TenantID: tenantID, UserID: userID, DonorEmail: data.Attributes.UserEmail, DonorName: data.Attributes.UserName, AmountCents: data.Attributes.TotalUsd, LemonOrderID: data.ID, Message: message, } if err := h.store.CreateDonation(ctx, donation); err != nil { return fmt.Errorf("create donation: %w", err) } grossCents := data.Attributes.TotalUsd platformFeeCents := grossCents * 5 / 100 processorFeeCents := grossCents * 5 / 100 netCents := grossCents - platformFeeCents - processorFeeCents description := fmt.Sprintf("Donation from %s", data.Attributes.UserName) if err := h.store.AddEarnings(ctx, tenantID, "donation", donation.ID, description, grossCents, platformFeeCents, processorFeeCents, netCents); err != nil { return fmt.Errorf("add earnings: %w", err) } slog.Info("donation recorded", "tenant_id", tenantID, "donor", data.Attributes.UserEmail, "gross_cents", grossCents, ) return nil } func normalizeStatus(lemonStatus string) string { switch lemonStatus { case "on_trial", "active": return "active" case "paused", "past_due", "unpaid": return "past_due" case "cancelled", "expired": return "cancelled" default: return lemonStatus } }