writekit/internal/billing/webhook.go
2026-01-09 00:16:46 +02:00

303 lines
8.5 KiB
Go

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
}
}