feat(admin): Paddle webhook ingress — verify, idempotent claim, dispatch

This commit is contained in:
2026-07-27 10:37:53 +01:00
parent fbd93d0ea5
commit 6832bfd7bb
3 changed files with 127 additions and 0 deletions
+64
View File
@@ -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})
}
}
+4
View File
@@ -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())
{
+59
View File
@@ -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 }