init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
303
internal/billing/webhook.go
Normal file
303
internal/billing/webhook.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue