diff --git a/admin/internal/api/paddle.go b/admin/internal/api/paddle.go new file mode 100644 index 0000000..c387eeb --- /dev/null +++ b/admin/internal/api/paddle.go @@ -0,0 +1,64 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/billing" + "github.com/mrhid6/vantage/admin/internal/config" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" +) + +// paddleWebhook is the ingress for every Paddle event. +// +// Order is load-bearing: read the RAW body first (the signature is over the +// exact bytes), verify, THEN claim the event ID, THEN dispatch. A bad signature +// is 401 and processes nothing; a duplicate of a handled event is 200 and does +// nothing; a handler error is 500 so Paddle retries, and is recorded for staff. +func paddleWebhook(cfg config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"}) + return + } + if !paddle.VerifySignature(cfg.PaddleWebhookSecret, + c.GetHeader("Paddle-Signature"), body) { + log.Printf("paddle webhook: bad signature from %s", c.ClientIP()) + c.JSON(http.StatusUnauthorized, gin.H{"error": "bad signature"}) + return + } + + var ev billing.Event + if err := json.Unmarshal(body, &ev); err != nil || ev.EventID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"}) + return + } + + ctx := c.Request.Context() + claimed, err := models.ClaimEvent(ctx, ev.EventID, ev.EventType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"}) + return + } + if !claimed { + // Already handled (or in flight). 200 so Paddle stops retrying. + c.JSON(http.StatusOK, gin.H{"duplicate": true}) + return + } + + if err := billing.Dispatch(ctx, ev); err != nil { + log.Printf("paddle webhook: handler %s failed for %s: %v", + ev.EventType, ev.EventID, err) + _ = models.MarkEventProcessed(ctx, ev.EventID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"}) + return + } + _ = models.MarkEventProcessed(ctx, ev.EventID, nil) + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index 661ee22..efb5260 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -39,6 +39,10 @@ func Routes(cfg config.Config) http.Handler { r.POST("/auth/signup", auth.HandleSignup) r.POST("/auth/accept-invite", auth.HandleAcceptInvite) + // Public: Paddle carries no session cookie; its signature is its auth. Must + // NOT sit under the cust group's session middleware. + r.POST("/api/paddle/webhook", paddleWebhook(cfg)) + cust := r.Group("/api") cust.Use(auth.RequireCustomer()) { diff --git a/admin/internal/billing/events.go b/admin/internal/billing/events.go new file mode 100644 index 0000000..4b2b4ec --- /dev/null +++ b/admin/internal/billing/events.go @@ -0,0 +1,59 @@ +// Package billing turns verified Paddle webhooks into licence actions. It never +// verifies signatures (that is paddle.VerifySignature at the edge) and never +// signs (that is licensing.Issue); it decides what a subscription's current +// state means and calls the issuer. +package billing + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// Event is the decoded Paddle webhook envelope. Data is left raw so each handler +// decodes only the shape it needs. +type Event struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + OccurredAt time.Time `json:"occurred_at"` + Data json.RawMessage `json:"data"` +} + +// Dispatch routes one event to its handler. Unknown event types are a no-op +// success: Paddle sends many we do not care about, and 200 stops it retrying. +func Dispatch(ctx context.Context, ev Event) error { + switch ev.EventType { + case "subscription.created", "subscription.updated", "subscription.activated": + return handleSubscription(ctx, ev) + case "subscription.canceled": + return handleCanceled(ctx, ev) + case "subscription.past_due": + return handlePastDue(ctx, ev) + case "transaction.completed": + return handleTransactionCompleted(ctx, ev) + case "transaction.payment_failed": + return handlePaymentFailed(ctx, ev) + case "customer.updated": + return handleCustomerUpdated(ctx, ev) + default: + return nil + } +} + +// decode is a small helper so every handler decodes Data the same way. +func decode[T any](ev Event) (T, error) { + var v T + if err := json.Unmarshal(ev.Data, &v); err != nil { + return v, fmt.Errorf("decode %s: %w", ev.EventType, err) + } + return v, nil +} + +// Stubs replaced in tasks 3 and 4. +func handleSubscription(ctx context.Context, ev Event) error { return nil } +func handleCanceled(ctx context.Context, ev Event) error { return nil } +func handlePastDue(ctx context.Context, ev Event) error { return nil } +func handleTransactionCompleted(ctx context.Context, ev Event) error { return nil } +func handlePaymentFailed(ctx context.Context, ev Event) error { return nil } +func handleCustomerUpdated(ctx context.Context, ev Event) error { return nil }