303 lines
8.5 KiB
Go
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
|
|
}
|
|
}
|