diff --git a/admin/cmd/main.go b/admin/cmd/main.go index d883f19..d568e26 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -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, diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go index bcf8c8a..e1a2fcb 100644 --- a/admin/internal/config/config.go +++ b/admin/internal/config/config.go @@ -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) diff --git a/admin/internal/db/db.go b/admin/internal/db/db.go index 67e4b19..3b29e12 100644 --- a/admin/internal/db/db.go +++ b/admin/internal/db/db.go @@ -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"}, } diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index d0f0e77..13bf5f7 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -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 diff --git a/admin/internal/models/paddle_events.go b/admin/internal/models/paddle_events.go new file mode 100644 index 0000000..c4e7722 --- /dev/null +++ b/admin/internal/models/paddle_events.go @@ -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 +} diff --git a/admin/internal/paddle/client.go b/admin/internal/paddle/client.go new file mode 100644 index 0000000..e133e6c --- /dev/null +++ b/admin/internal/paddle/client.go @@ -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 +} diff --git a/admin/internal/paddle/http.go b/admin/internal/paddle/http.go new file mode 100644 index 0000000..2b94371 --- /dev/null +++ b/admin/internal/paddle/http.go @@ -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 +} diff --git a/admin/internal/paddle/webhook.go b/admin/internal/paddle/webhook.go new file mode 100644 index 0000000..1bccfbf --- /dev/null +++ b/admin/internal/paddle/webhook.go @@ -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=;h1=". +// 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)) +}