feat(admin): Paddle client behind an interface, config, and the event idempotency record

Client is a thin REST client (net/http) rather than the vendor SDK: the surface
we need is two calls, and a hand-rolled client has no version-drift risk and no
dependency in go.sum. All Paddle wire shapes live only in http.go.

PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are boot-required — an unverified
webhook endpoint is one anyone can issue licences through. paddle_events carries
a unique index on event_id for webhook idempotency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:36:26 +01:00
co-authored by Claude Opus 5
parent 3d3be7465f
commit fbd93d0ea5
8 changed files with 323 additions and 1 deletions
+5
View File
@@ -21,6 +21,7 @@ import (
"github.com/mrhid6/vantage/admin/internal/lifecycle"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
"github.com/mrhid6/vantage/admin/internal/paddle"
)
func main() {
@@ -34,6 +35,10 @@ func main() {
licensing.SetSigningKey(cfg.SigningKey)
api.SetAppLoginURL(cfg.AppLoginURL)
if _, err := paddle.Init(cfg.PaddleAPIKey, cfg.PaddleEnv); err != nil {
log.Fatalf("paddle init: %v", err)
}
mail.Init(mail.Config{
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
+12
View File
@@ -29,6 +29,10 @@ type Config struct {
Addr string
ReapAfter time.Duration
PaddleEnv string // "sandbox" or "production"
PaddleAPIKey string
PaddleWebhookSecret string
SMTPHost string
SMTPPort string
SMTPFrom string
@@ -75,6 +79,10 @@ func Load() (Config, error) {
SMTPFrom: os.Getenv("SMTP_FROM"),
SMTPUsername: os.Getenv("SMTP_USERNAME"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
PaddleEnv: envOr("PADDLE_ENV", "sandbox"),
PaddleAPIKey: os.Getenv("PADDLE_API_KEY"),
PaddleWebhookSecret: os.Getenv("PADDLE_WEBHOOK_SECRET"),
}
var missing []string
@@ -85,6 +93,10 @@ func Load() (Config, error) {
"LICENSE_SIGNING_KEY": c.SigningKey,
"PUBLIC_URL": c.PublicURL,
"ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"),
// An unverified webhook endpoint is one anyone can issue licences
// through, so the secret and API key are boot-required.
"PADDLE_API_KEY": c.PaddleAPIKey,
"PADDLE_WEBHOOK_SECRET": c.PaddleWebhookSecret,
} {
if v == "" {
missing = append(missing, name)
+1
View File
@@ -85,6 +85,7 @@ func EnsureIndexes(ctx context.Context) error {
{"accounts", "account_id"},
{"admin_instances", "instance_id"},
{"licenses", "license_id"},
{"paddle_events", "event_id"},
{"staff_users", "email"},
{"customer_users", "email"},
}
+41 -1
View File
@@ -77,6 +77,21 @@ const (
ReasonEntitlementChange = "entitlement_change"
)
// Subscription statuses, mirrored from Paddle. Ours, not a vendor SDK's, so the
// billing package does not import anything Paddle.
const (
SubActive = "active"
SubCanceled = "canceled"
SubPastDue = "past_due"
SubTrialing = "trialing"
)
// Billing terms. These match catalogue price-ID keys and license.TermsFor.
const (
TermMonthly = "monthly"
TermAnnual = "annual"
)
// MaxRelinksPerTerm is the customer-facing relink cap.
//
// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be
@@ -128,6 +143,10 @@ type Instance struct {
// clears it, so the next term starts the sequence again. It is what stops a
// restart re-sending a notice.
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
// Placeholder is true while a self-hosted instance row exists only so a
// checkout has something to attach custom_data to, before the customer has
// pasted their install's real UUID. Cleared when the instance is linked.
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
@@ -158,11 +177,32 @@ type Subscription struct {
AccountID string `bson:"account_id" json:"account_id"`
InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"`
PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"`
PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"`
Tier string `bson:"tier" json:"tier"`
Term string `bson:"term" json:"term"`
Status string `bson:"status" json:"status"`
CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"`
// Items is the full line-item list. Spec 7 made a subscription several
// prices — a base, a per-server unit at quantity N, an item per paid
// feature — so a single price ID can no longer describe it.
Items []SubItem `bson:"items,omitempty" json:"items,omitempty"`
}
// SubItem is one line of a subscription: a price and its quantity, the shape
// catalogue.ResolveItems reads back into a plan and configuration.
type SubItem struct {
PriceID string `bson:"price_id" json:"price_id"`
Quantity int `bson:"quantity" json:"quantity"`
}
// PaddleEvent is the idempotency record for one webhook delivery. The unique
// index on EventID is what makes a retry a no-op rather than a second licence.
type PaddleEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
EventID string `bson:"event_id" json:"event_id"`
EventType string `bson:"event_type" json:"event_type"`
ReceivedAt time.Time `bson:"received_at" json:"received_at"`
ProcessedAt *time.Time `bson:"processed_at,omitempty" json:"processed_at,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
}
// Plan is the authoritative definition of one (deployment, tier) pair, seeded
+48
View File
@@ -0,0 +1,48 @@
package models
import (
"context"
"time"
"github.com/mrhid6/vantage/admin/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ClaimEvent records an event ID before it is processed and reports whether THIS
// call is the one that claimed it.
//
// The unique index on event_id turns a duplicate insert into a duplicate-key
// error, which is the signal that another delivery of the same event already
// owns it — so this returns (false, nil) and the caller answers 200 without
// acting. A genuine error returns (false, err).
func ClaimEvent(ctx context.Context, eventID, eventType string) (bool, error) {
_, err := db.Admin("paddle_events").InsertOne(ctx, PaddleEvent{
EventID: eventID,
EventType: eventType,
ReceivedAt: time.Now().UTC(),
})
if err == nil {
return true, nil
}
if mongo.IsDuplicateKeyError(err) {
return false, nil
}
return false, err
}
// MarkEventProcessed stamps success, or records the error for staff visibility.
// A failed event keeps no processed_at, so a retry re-runs it.
func MarkEventProcessed(ctx context.Context, eventID string, procErr error) error {
set := bson.M{}
if procErr != nil {
set["error"] = procErr.Error()
} else {
now := time.Now().UTC()
set["processed_at"] = now
set["error"] = ""
}
_, err := db.Admin("paddle_events").UpdateOne(ctx,
bson.M{"event_id": eventID}, bson.M{"$set": set})
return err
}
+54
View File
@@ -0,0 +1,54 @@
// Package paddle is the only place that talks to Paddle. Everything outside it
// depends on the Client interface and our own types, never on Paddle's wire
// shapes — so a change at Paddle is confined to http.go, and the billing package
// can be reasoned about without knowing Paddle exists.
//
// It is a thin REST client rather than the vendor SDK on purpose: the surface we
// need is two calls, and a hand-rolled client has no version-drift risk and no
// dependency to keep in go.sum.
package paddle
import "context"
// LineItem is one price at a quantity, the shape both a checkout and a
// subscription update are built from.
type LineItem struct {
PriceID string
Quantity int
}
// Client is the narrow slice of Paddle admin needs. Checkout itself happens in
// the browser via paddle-js; the server only updates an existing subscription
// and mints a portal session.
type Client interface {
// UpdateSubscriptionItems replaces a subscription's items, prorated
// immediately by Paddle. This is the one outbound mutation, used when a
// customer changes their server count or features on an existing plan.
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
// PortalSession returns a customer-portal URL for managing billing.
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
// Env is "sandbox" or "production", the same value catalogue price lookups
// are keyed on.
Env() string
}
var current Client
// Init constructs the client from config and stores it. Called once at boot.
func Init(apiKey, env string) (Client, error) {
c, err := newHTTPClient(apiKey, env)
if err != nil {
return nil, err
}
current = c
return c, nil
}
// Get returns the client initialised at boot. Panics if unset, which can only
// happen if a caller runs before Init — a programming error, not a runtime one.
func Get() Client {
if current == nil {
panic("paddle.Get before paddle.Init")
}
return current
}
+120
View File
@@ -0,0 +1,120 @@
package paddle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// httpClient is the only implementation of Client. It is the single place that
// knows Paddle's base URLs, auth header and request shapes — swap the whole
// vendor here without the rest of the tree noticing.
type httpClient struct {
apiKey string
env string
base string
http *http.Client
}
func newHTTPClient(apiKey, env string) (Client, error) {
if apiKey == "" {
return nil, fmt.Errorf("paddle: empty API key")
}
base := "https://sandbox-api.paddle.com"
if env == "production" {
base = "https://api.paddle.com"
}
return &httpClient{
apiKey: apiKey,
env: env,
base: base,
http: &http.Client{Timeout: 20 * time.Second},
}, nil
}
func (c *httpClient) Env() string { return c.env }
// do sends a JSON request and decodes the `data` envelope Paddle wraps every
// response in. A non-2xx is returned as an error carrying the body, so a
// configuration or auth failure is loud rather than silent.
func (c *httpClient) do(ctx context.Context, method, path string, body any, out any) error {
var buf io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("paddle: marshal %s %s: %w", method, path, err)
}
buf = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, buf)
if err != nil {
return fmt.Errorf("paddle: build %s %s: %w", method, path, err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("paddle: %s %s: %w", method, path, err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("paddle: %s %s returned %d: %s", method, path, res.StatusCode, string(raw))
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("paddle: decode %s %s: %w", method, path, err)
}
return nil
}
type updateSubscriptionRequest struct {
Items []reqItem `json:"items"`
ProrationBillingMode string `json:"proration_billing_mode"`
}
type reqItem struct {
PriceID string `json:"price_id"`
Quantity int `json:"quantity"`
}
func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string, items []LineItem) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
reqItems := make([]reqItem, 0, len(items))
for _, it := range items {
reqItems = append(reqItems, reqItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, updateSubscriptionRequest{
Items: reqItems,
ProrationBillingMode: "prorated_immediately",
}, nil)
}
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
if customerID == "" {
return "", fmt.Errorf("paddle: empty customer id")
}
var out struct {
Data struct {
URLs struct {
General struct {
Overview string `json:"overview"`
} `json:"general"`
} `json:"urls"`
} `json:"data"`
}
if err := c.do(ctx, http.MethodPost,
"/customers/"+customerID+"/portal-sessions", struct{}{}, &out); err != nil {
return "", err
}
return out.Data.URLs.General.Overview, nil
}
+42
View File
@@ -0,0 +1,42 @@
package paddle
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
// VerifySignature checks a raw webhook body against the Paddle-Signature header.
//
// Paddle signs an HMAC-SHA256 over "ts:body", carried as "ts=<unix>;h1=<hex>".
// It uses a constant-time compare and never logs the secret. A false return is
// always a 401 with nothing processed — an unverified body could be anyone
// claiming a subscription was paid for.
func VerifySignature(secret, header string, body []byte) bool {
if secret == "" || header == "" {
return false
}
var ts, h1 string
for _, part := range strings.Split(header, ";") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch k {
case "ts":
ts = v
case "h1":
h1 = v
}
}
if ts == "" || h1 == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte(":"))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(h1))
}