Compare commits
9
Commits
3d3be7465f
...
0a86167c44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a86167c44 | ||
|
|
3537ec59dc | ||
|
|
8bbecd2035 | ||
|
|
c10f093cad | ||
|
|
28b138b4c3 | ||
|
|
01bb37125d | ||
|
|
2e8114c41e | ||
|
|
6832bfd7bb | ||
|
|
fbd93d0ea5 |
@@ -147,6 +147,8 @@ jobs:
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
|
||||
-t "$IMAGE" \
|
||||
-f adminsite/Dockerfile adminsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/audit"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/billing"
|
||||
"github.com/mrhid6/vantage/admin/internal/catalogue"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/admin/internal/paddle"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// checkoutOptions serves everything the browser configurator needs to price a
|
||||
// plan: the active plans (base allowances), the full catalogue (component prices
|
||||
// in the running environment), and the environment name so the client can refuse
|
||||
// a mismatch. The client token itself is baked into the adminsite build, never
|
||||
// served from here.
|
||||
func checkoutOptions(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
plans := []models.Plan{}
|
||||
if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil {
|
||||
_ = cur.All(ctx, &plans)
|
||||
}
|
||||
rows, err := models.AllCatalogue(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"plans": plans,
|
||||
"catalogue": rows,
|
||||
"env": paddle.Get().Env(),
|
||||
})
|
||||
}
|
||||
|
||||
// createSelfHostedPlaceholder makes an instance row that exists only so a
|
||||
// checkout has something to put in custom_data. It carries no licence and is
|
||||
// flagged Placeholder until the customer pastes their install's real UUID. The
|
||||
// generated id is temporary; linking replaces the identity.
|
||||
func createSelfHostedPlaceholder(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
inst := models.Instance{
|
||||
InstanceID: uuid.NewString(),
|
||||
AccountID: s.AccountID,
|
||||
Name: body.Name,
|
||||
Deployment: license.DeploymentSelfHosted,
|
||||
Status: models.StatusAwaitingLink,
|
||||
Placeholder: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
|
||||
}
|
||||
|
||||
// updateEntitlement sets an instance's DESIRED configuration and pushes the
|
||||
// resulting line items to Paddle. It does NOT issue — the resulting
|
||||
// subscription.updated webhook does, from granted. An increase is prorated
|
||||
// immediately by Paddle; a reduction is recorded as desired and takes effect at
|
||||
// renewal, so this never shrinks a live licence.
|
||||
func updateEntitlement(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
var body struct {
|
||||
Tier string `json:"tier"`
|
||||
Term string `json:"term"`
|
||||
Servers int `json:"servers"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"})
|
||||
return
|
||||
}
|
||||
if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)})
|
||||
return
|
||||
}
|
||||
|
||||
desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()}
|
||||
items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// A live subscription is required to update. None means this instance has
|
||||
// never been paid for — that is a checkout, not an update.
|
||||
var sub models.Subscription
|
||||
if err := db.Admin("subscriptions").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"})
|
||||
return
|
||||
}
|
||||
|
||||
pItems := make([]paddle.LineItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity})
|
||||
}
|
||||
if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Record desired now; the webhook Paddle sends back promotes to granted and
|
||||
// reissues. Recording here makes the portal reflect the intent instantly
|
||||
// rather than waiting on the round-trip.
|
||||
limits, _, _ := catalogue.Resolve(ctx, plan, desired)
|
||||
next := models.Entitlement{
|
||||
InstanceID: inst.InstanceID, AccountID: inst.AccountID,
|
||||
Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term,
|
||||
Desired: desired, ResolvedLimits: limits,
|
||||
}
|
||||
ent, _ := models.GetEntitlement(ctx, inst.InstanceID)
|
||||
if ent != nil {
|
||||
next.Granted = ent.Granted
|
||||
next.GrantedAt = ent.GrantedAt
|
||||
if desired.Servers < ent.Granted.Servers {
|
||||
now := time.Now().UTC()
|
||||
next.ScheduledChangeAt = &now
|
||||
}
|
||||
} else {
|
||||
next.Granted = desired
|
||||
}
|
||||
if err := models.UpsertEntitlement(ctx, next); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "entitlement.requested",
|
||||
AccountID: inst.AccountID, Target: inst.InstanceID})
|
||||
c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
|
||||
}
|
||||
|
||||
// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's
|
||||
// real install UUID, then issues.
|
||||
//
|
||||
// :id is the placeholder (generated at checkout, carried in the subscription's
|
||||
// custom_data); the body carries the UUID the install actually reports. The
|
||||
// licence must bind to that real UUID (spec 1 has no unbound licence), so the
|
||||
// placeholder row's identity is rewritten to it and the subscription re-pointed,
|
||||
// then billing issues from the recorded subscription. Linking and claiming stay
|
||||
// one call here because, unlike Free, the payment already happened.
|
||||
func claimPlaceholderLink(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !inst.Placeholder {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// The real UUID must be free across every account — the unique index on
|
||||
// instance_id is the tenant-isolation property, so refuse rather than collide.
|
||||
if n, _ := db.Admin("admin_instances").CountDocuments(ctx,
|
||||
bson.M{"instance_id": body.InstanceID}); n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"})
|
||||
return
|
||||
}
|
||||
|
||||
placeholderID := inst.InstanceID
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{"$set": bson.M{
|
||||
"instance_id": body.InstanceID,
|
||||
"status": models.StatusActive,
|
||||
"placeholder": false,
|
||||
}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-point the subscription from the placeholder id to the real UUID so
|
||||
// billing.IssueForInstance (and every later webhook) finds it.
|
||||
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil {
|
||||
// The link stuck; issuance did not. The reconciler and a retry recover it,
|
||||
// and the customer is not blocked from linking. Surface it, do not roll back.
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"instance_id": body.InstanceID,
|
||||
"warning": "linked, but licence issuance is pending: " + err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.placeholder_linked",
|
||||
AccountID: inst.AccountID, Target: body.InstanceID,
|
||||
Detail: "from placeholder " + placeholderID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID})
|
||||
}
|
||||
|
||||
// billingPortal mints a Paddle customer-portal URL. The account must already
|
||||
// have a paddle_customer_id, which it learns from its first subscription webhook.
|
||||
func billingPortal(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
ctx := c.Request.Context()
|
||||
var acc models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx,
|
||||
bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no account"})
|
||||
return
|
||||
}
|
||||
if acc.PaddleCustomerID == "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"})
|
||||
return
|
||||
}
|
||||
url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
{
|
||||
@@ -72,6 +76,17 @@ func Routes(cfg config.Config) http.Handler {
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
claimFree)
|
||||
cust.GET("/instances/:id/entitlement", getEntitlement)
|
||||
cust.GET("/checkout/options", checkoutOptions)
|
||||
cust.POST("/instances/self-hosted",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createSelfHostedPlaceholder)
|
||||
cust.PUT("/instances/:id/entitlement",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateEntitlement)
|
||||
cust.POST("/billing/portal", billingPortal)
|
||||
cust.POST("/instances/:id/claim-link",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
claimPlaceholderLink)
|
||||
cust.GET("/instances/:id/license", getInstanceLicense)
|
||||
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
|
||||
cust.GET("/instances/:id/members", listInstanceMembers)
|
||||
@@ -113,6 +128,7 @@ func Routes(cfg config.Config) http.Handler {
|
||||
staff.PUT("/instances/:id/entitlement", staffSetEntitlement)
|
||||
staff.GET("/audit", staffAudit)
|
||||
staff.GET("/health/injection", staffInjectionHealth)
|
||||
staff.GET("/health/billing", staffBillingHealth)
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
@@ -380,6 +380,29 @@ func staffListLicenses(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, lics)
|
||||
}
|
||||
|
||||
// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked
|
||||
// placeholders, so a customer who paid and got nothing is visible rather than
|
||||
// stuck in a support queue.
|
||||
func staffBillingHealth(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
failed := []models.PaddleEvent{}
|
||||
if cur, err := db.Admin("paddle_events").Find(ctx,
|
||||
bson.M{"processed_at": bson.M{"$exists": false}, "error": bson.M{"$ne": ""}}); err == nil {
|
||||
_ = cur.All(ctx, &failed)
|
||||
}
|
||||
unlinked := []models.Instance{}
|
||||
if cur, err := db.Admin("admin_instances").Find(ctx,
|
||||
bson.M{"placeholder": true, "status": models.StatusAwaitingLink}); err == nil {
|
||||
_ = cur.All(ctx, &unlinked)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"failed_events": failed,
|
||||
"failed_count": len(failed),
|
||||
"unlinked_paid": unlinked,
|
||||
"unlinked_count": len(unlinked),
|
||||
})
|
||||
}
|
||||
|
||||
func staffListPlans(c *gin.Context) {
|
||||
cur, err := db.Admin("plans").Find(c.Request.Context(), bson.M{})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/inject"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
)
|
||||
|
||||
// deliver sends a freshly issued licence where it belongs. Cloud is injected;
|
||||
// self-hosted is emailed the blob (their database is theirs). This mirrors the
|
||||
// api-side deliver helper but takes no gin context — webhooks have none, and the
|
||||
// customer is not on the other end of the request.
|
||||
func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to string) {
|
||||
if inst.Deployment == license.DeploymentCloud {
|
||||
inject.Deliver(ctx, lic)
|
||||
return
|
||||
}
|
||||
if to != "" && mail.Enabled() {
|
||||
_ = mail.SendLicense(to, inst.Name, lic.Blob)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/catalogue"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/licensing"
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/admin/internal/paddle"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// subscriptionData is the slice of Paddle's subscription payload we read. Fields
|
||||
// we ignore are simply absent — encoding/json drops them.
|
||||
type subscriptionData struct {
|
||||
ID string `json:"id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Status string `json:"status"`
|
||||
CustomData struct {
|
||||
AccountID string `json:"account_id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
} `json:"custom_data"`
|
||||
CurrentBillingPeriod struct {
|
||||
EndsAt time.Time `json:"ends_at"`
|
||||
} `json:"current_billing_period"`
|
||||
Items []struct {
|
||||
Price struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"price"`
|
||||
Quantity int `json:"quantity"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
func (d subscriptionData) lineItems() []catalogue.Item {
|
||||
items := make([]catalogue.Item, 0, len(d.Items))
|
||||
for _, it := range d.Items {
|
||||
items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// handleSubscription folds created/updated/activated into one job: make the
|
||||
// world match the subscription's CURRENT state. That is what keeps out-of-order
|
||||
// delivery correct — an updated arriving before its created still carries the
|
||||
// full item list, so reading all of it is reading current state, not a
|
||||
// transition.
|
||||
func handleSubscription(ctx context.Context, ev Event) error {
|
||||
d, err := decode[subscriptionData](ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.CustomData.InstanceID == "" {
|
||||
return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID)
|
||||
}
|
||||
|
||||
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems())
|
||||
if err != nil {
|
||||
// A price we cannot map is a configuration error, not a customer error.
|
||||
// Fail loudly so it is retried and surfaced rather than guessed.
|
||||
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
|
||||
}
|
||||
|
||||
sub := models.Subscription{
|
||||
AccountID: d.CustomData.AccountID,
|
||||
InstanceID: d.CustomData.InstanceID,
|
||||
PaddleSubscriptionID: d.ID,
|
||||
Tier: match.Tier,
|
||||
Term: match.Term,
|
||||
Status: d.Status,
|
||||
CurrentPeriodEnd: d.CurrentBillingPeriod.EndsAt,
|
||||
Items: toSubItems(d.lineItems()),
|
||||
}
|
||||
if err := upsertSubscription(ctx, sub); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Learn the Paddle customer ID onto the account the first time we see it.
|
||||
if d.CustomerID != "" && d.CustomData.AccountID != "" {
|
||||
_, _ = db.Admin("accounts").UpdateOne(ctx,
|
||||
bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}},
|
||||
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
|
||||
}
|
||||
|
||||
// A self-hosted placeholder that has not been linked yet gets its
|
||||
// subscription recorded but NO licence — there is no UUID to bind to. The
|
||||
// link endpoint issues when the customer pastes it.
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
|
||||
return fmt.Errorf("subscription %s names unknown instance %s: %w",
|
||||
d.ID, d.CustomData.InstanceID, err)
|
||||
}
|
||||
if inst.Placeholder {
|
||||
return nil
|
||||
}
|
||||
|
||||
return promoteAndIssue(ctx, &inst, match, models.ReasonEntitlementChange)
|
||||
}
|
||||
|
||||
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
|
||||
// licence from granted. This is the only promotion path other than the staff
|
||||
// grant, and it exists because a webhook is a confirmed payment.
|
||||
func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error {
|
||||
plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err)
|
||||
}
|
||||
granted := models.Config{
|
||||
Servers: match.Servers,
|
||||
Features: models.Features(match.Features).OrEmpty(),
|
||||
}
|
||||
limits, _, err := catalogue.Resolve(ctx, plan, granted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := models.UpsertEntitlement(ctx, models.Entitlement{
|
||||
InstanceID: inst.InstanceID,
|
||||
AccountID: inst.AccountID,
|
||||
Deployment: inst.Deployment,
|
||||
Tier: match.Tier,
|
||||
Term: match.Term,
|
||||
Desired: granted,
|
||||
Granted: granted,
|
||||
ResolvedLimits: limits,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lic, err := licensing.Issue(ctx, licensing.IssueInput{
|
||||
InstanceID: inst.InstanceID,
|
||||
Tier: match.Tier,
|
||||
Term: match.Term,
|
||||
Reason: reason,
|
||||
IssuedBy: "paddle",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("issue for %s: %w", inst.InstanceID, err)
|
||||
}
|
||||
deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action.
|
||||
//
|
||||
// The instance stays active until its licence expires, when the existing
|
||||
// lifecycle sweep lapses it. Flipping the instance to cancelled here would stop
|
||||
// inject.Reconcile and the sweep repairing a licence that is still valid — the
|
||||
// opposite of "keeps working until it expires".
|
||||
func handleCanceled(ctx context.Context, ev Event) error {
|
||||
d, err := decode[subscriptionData](ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Admin("subscriptions").UpdateOne(ctx,
|
||||
bson.M{"paddle_subscription_id": d.ID},
|
||||
bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
|
||||
_ = mail.SendCancelled(to, instanceNameFor(ctx, d.CustomData.InstanceID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePastDue flags the subscription and notifies, but leaves the licence
|
||||
// alone. Dunning is Paddle's; ours is not to punish a retryable card failure.
|
||||
func handlePastDue(ctx context.Context, ev Event) error {
|
||||
d, err := decode[subscriptionData](ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Admin("subscriptions").UpdateOne(ctx,
|
||||
bson.M{"paddle_subscription_id": d.ID},
|
||||
bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
|
||||
_ = mail.SendPastDue(to, instanceNameFor(ctx, d.CustomData.InstanceID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCustomerUpdated syncs the billing email onto the account.
|
||||
func handleCustomerUpdated(ctx context.Context, ev Event) error {
|
||||
d, err := decode[struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}](ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.ID == "" || d.Email == "" {
|
||||
return nil
|
||||
}
|
||||
_, err = db.Admin("accounts").UpdateOne(ctx,
|
||||
bson.M{"paddle_customer_id": d.ID},
|
||||
bson.M{"$set": bson.M{"billing_email": d.Email}})
|
||||
return err
|
||||
}
|
||||
|
||||
// IssueForInstance issues from an instance's recorded subscription. Called when
|
||||
// a self-hosted customer finally links a placeholder they have already paid for.
|
||||
func IssueForInstance(ctx context.Context, instanceID string) error {
|
||||
var sub models.Subscription
|
||||
if err := db.Admin("subscriptions").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
|
||||
return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
|
||||
}
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]catalogue.Item, 0, len(sub.Items))
|
||||
for _, it := range sub.Items {
|
||||
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
|
||||
}
|
||||
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
|
||||
}
|
||||
|
||||
func toSubItems(items []catalogue.Item) []models.SubItem {
|
||||
out := make([]models.SubItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func upsertSubscription(ctx context.Context, sub models.Subscription) error {
|
||||
_, err := db.Admin("subscriptions").UpdateOne(ctx,
|
||||
bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID},
|
||||
bson.M{"$set": bson.M{
|
||||
"account_id": sub.AccountID,
|
||||
"instance_id": sub.InstanceID,
|
||||
"tier": sub.Tier,
|
||||
"term": sub.Term,
|
||||
"status": sub.Status,
|
||||
"current_period_end": sub.CurrentPeriodEnd,
|
||||
"items": sub.Items,
|
||||
}, "$setOnInsert": bson.M{
|
||||
"subscription_id": uuid.NewString(),
|
||||
"paddle_subscription_id": sub.PaddleSubscriptionID,
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
return err
|
||||
}
|
||||
|
||||
// billingEmailFor reads the account's billing email for self-hosted delivery.
|
||||
func billingEmailFor(ctx context.Context, accountID string) string {
|
||||
var acc models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx,
|
||||
bson.M{"account_id": accountID}).Decode(&acc); err != nil {
|
||||
return ""
|
||||
}
|
||||
return acc.BillingEmail
|
||||
}
|
||||
|
||||
// instanceNameFor is a best-effort display name for an email subject.
|
||||
func instanceNameFor(ctx context.Context, instanceID string) string {
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" {
|
||||
return "your instance"
|
||||
}
|
||||
return inst.Name
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/catalogue"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/admin/internal/paddle"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type transactionData struct {
|
||||
ID string `json:"id"`
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
Origin string `json:"origin"`
|
||||
Items []struct {
|
||||
Price struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"price"`
|
||||
Quantity int `json:"quantity"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// handleTransactionCompleted issues the next term's licence on a renewal.
|
||||
//
|
||||
// A renewal is the one moment a scheduled REDUCTION takes effect: the customer's
|
||||
// desired (smaller) configuration becomes granted. Mid-term reductions never
|
||||
// shrink a live licence. On a first charge (origin not recurring) the
|
||||
// subscription.created/updated handler already issued, so this is a no-op to
|
||||
// avoid a double issue.
|
||||
func handleTransactionCompleted(ctx context.Context, ev Event) error {
|
||||
d, err := decode[transactionData](ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Origin != "subscription_recurring" {
|
||||
return nil
|
||||
}
|
||||
if d.SubscriptionID == "" {
|
||||
return fmt.Errorf("renewal transaction %s has no subscription_id", d.ID)
|
||||
}
|
||||
|
||||
var sub models.Subscription
|
||||
if err := db.Admin("subscriptions").FindOne(ctx,
|
||||
bson.M{"paddle_subscription_id": d.SubscriptionID}).Decode(&sub); err != nil {
|
||||
return fmt.Errorf("renewal for unknown subscription %s: %w", d.SubscriptionID, err)
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": sub.InstanceID}).Decode(&inst); err != nil {
|
||||
return fmt.Errorf("renewal names unknown instance %s: %w", sub.InstanceID, err)
|
||||
}
|
||||
|
||||
// Prefer the transaction's own item list (authoritative for this period);
|
||||
// fall back to the subscription's recorded items.
|
||||
items := make([]catalogue.Item, 0, len(d.Items))
|
||||
for _, it := range d.Items {
|
||||
items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
for _, it := range sub.Items {
|
||||
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
|
||||
}
|
||||
}
|
||||
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve renewal items for %s: %w", d.SubscriptionID, err)
|
||||
}
|
||||
|
||||
// Collapse a scheduled reduction: desired becomes granted, and the pending
|
||||
// marker is cleared, since a new term has begun. This is the only place a
|
||||
// licence ever gets a smaller cap.
|
||||
if err := promoteScheduledReduction(ctx, inst.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Issue the next term. Renewal resets relink_count inside licensing.Issue.
|
||||
if err := promoteAndIssue(ctx, &inst, match, models.ReasonRenewal); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear lifecycle notices so the next term starts the sequence fresh (mirrors
|
||||
// the self-serve renew path).
|
||||
_, _ = db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$unset": bson.M{"notices_sent": ""}})
|
||||
return nil
|
||||
}
|
||||
|
||||
// promoteScheduledReduction collapses a pending reduction into granted at
|
||||
// renewal and clears scheduled_change_at. A no-op when nothing is pending — the
|
||||
// match resolved from the renewal's items is authoritative either way, so this
|
||||
// only keeps the entitlement's own bookkeeping honest.
|
||||
func promoteScheduledReduction(ctx context.Context, instanceID string) error {
|
||||
ent, err := models.GetEntitlement(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil // no entitlement to reconcile
|
||||
}
|
||||
if ent.ScheduledChangeAt == nil {
|
||||
return nil
|
||||
}
|
||||
ent.Granted = ent.Desired
|
||||
ent.ScheduledChangeAt = nil
|
||||
return models.UpsertEntitlement(ctx, *ent)
|
||||
}
|
||||
|
||||
// handlePaymentFailed records the failure for staff visibility. No licence
|
||||
// action — the licence runs to its (grace-padded) expiry and Paddle retries.
|
||||
func handlePaymentFailed(ctx context.Context, ev Event) error {
|
||||
d, err := decode[transactionData](ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.SubscriptionID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err = db.Admin("subscriptions").UpdateOne(ctx,
|
||||
bson.M{"paddle_subscription_id": d.SubscriptionID},
|
||||
bson.M{"$set": bson.M{"status": models.SubPastDue}})
|
||||
return err
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -172,4 +172,74 @@ func runOnce(ctx context.Context) {
|
||||
if err := Run(runCtx); err != nil {
|
||||
log.Printf("lifecycle: %v", err)
|
||||
}
|
||||
sweepAwaitingLink(runCtx)
|
||||
}
|
||||
|
||||
// Awaiting-link reminder keys.
|
||||
const (
|
||||
noticeLink24 = "link_24"
|
||||
noticeLink72 = "link_72"
|
||||
)
|
||||
|
||||
// sweepAwaitingLink chases self-hosted instances that were paid for but never
|
||||
// linked: the subscription exists, the instance is still a placeholder. It
|
||||
// emails a reminder at 24h and again at 72h. The staff dashboard already flags
|
||||
// 48h; this is the active chasing on top of that. It never issues or deletes.
|
||||
func sweepAwaitingLink(ctx context.Context) {
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentSelfHosted,
|
||||
"placeholder": true,
|
||||
"status": models.StatusAwaitingLink,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var instances []models.Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, inst := range instances {
|
||||
// Only chase placeholders a customer has actually paid for.
|
||||
n, err := db.Admin("subscriptions").CountDocuments(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive})
|
||||
if err != nil || n == 0 {
|
||||
continue
|
||||
}
|
||||
if !mail.Enabled() {
|
||||
continue
|
||||
}
|
||||
to := accountEmail(ctx, inst.AccountID)
|
||||
if to == "" {
|
||||
continue
|
||||
}
|
||||
age := now.Sub(inst.CreatedAt)
|
||||
var due string
|
||||
if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink72) {
|
||||
due = noticeLink72
|
||||
} else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink24) {
|
||||
due = noticeLink24
|
||||
}
|
||||
if due == "" {
|
||||
continue
|
||||
}
|
||||
if err := mail.SendLinkReminder(to, inst.Name); err != nil {
|
||||
log.Printf("lifecycle: link reminder %s for %s: %v", due, inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
|
||||
log.Printf("lifecycle: record link notice %s for %s: %v", due, inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func accountEmail(ctx context.Context, accountID string) string {
|
||||
var acct models.Account
|
||||
if err := db.Admin("accounts").FindOne(ctx,
|
||||
bson.M{"account_id": accountID}).Decode(&acct); err != nil {
|
||||
return ""
|
||||
}
|
||||
return acct.BillingEmail
|
||||
}
|
||||
|
||||
@@ -164,6 +164,32 @@ func SendLicense(to, instanceName, blob string) error {
|
||||
instanceName, blob))
|
||||
}
|
||||
|
||||
// SendCancelled confirms a cancellation and states what stays true: the licence
|
||||
// keeps working until it expires, then the instance degrades to read-only.
|
||||
func SendCancelled(to, instanceName string) error {
|
||||
return send(to, "Your Vantage subscription is cancelled",
|
||||
fmt.Sprintf("Your subscription for %s is cancelled.\n\n"+
|
||||
"Your instance keeps working until the current licence expires. After "+
|
||||
"that, monitors keep running but changes are disabled.\n", instanceName))
|
||||
}
|
||||
|
||||
// SendPastDue notifies of a failed charge without alarming: the licence is
|
||||
// untouched while Paddle retries the card.
|
||||
func SendPastDue(to, instanceName string) error {
|
||||
return send(to, "Payment failed for your Vantage subscription",
|
||||
fmt.Sprintf("A payment for %s failed.\n\n"+
|
||||
"Your instance is unaffected while the card is retried. Update your "+
|
||||
"payment method from the billing portal.\n", instanceName))
|
||||
}
|
||||
|
||||
// SendLinkReminder chases a self-hosted customer who paid but never linked.
|
||||
func SendLinkReminder(to, instanceName string) error {
|
||||
return send(to, "Finish setting up "+instanceName,
|
||||
fmt.Sprintf("Your subscription for %s is active, but the instance is not "+
|
||||
"linked yet.\n\nPaste your install's ID in the portal to receive your "+
|
||||
"licence.\n", instanceName))
|
||||
}
|
||||
|
||||
// SendInstanceReady tells a customer their cloud instance exists, where it is,
|
||||
// and when its licence runs out.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -19,6 +19,13 @@ ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_ENV=production
|
||||
ENV NEXT_PUBLIC_ADMIN_ENV=$NEXT_PUBLIC_ADMIN_ENV
|
||||
|
||||
# Browser checkout. The client token and environment are baked in, never
|
||||
# fetched, so a production build cannot load a sandbox token by accident.
|
||||
ARG NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=
|
||||
ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
|
||||
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
|
||||
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ApiError, api, lineItemsFor } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
import { CheckoutButton } from "@/components/CheckoutButton";
|
||||
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
|
||||
|
||||
/*
|
||||
* Three visible steps: name (creates a placeholder), configure + pay, then link
|
||||
* the real install UUID. The placeholder exists before payment so the webhook
|
||||
* has something to attach custom_data to; the licence is only issued once the
|
||||
* install's real UUID is known.
|
||||
*/
|
||||
export function PurchaseForm() {
|
||||
const router = useRouter();
|
||||
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
const options = useQuery({ queryKey: ["checkout-options"], queryFn: api.checkoutOptions });
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [placeholderId, setPlaceholderId] = useState<string | null>(null);
|
||||
const [uuid, setUuid] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [choice, setChoice] = useState<PlanChoice>({
|
||||
tier: "professional",
|
||||
term: "annual", // self-hosted is annual only
|
||||
servers: 3,
|
||||
features: [],
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createSelfHosted(name.trim()),
|
||||
onSuccess: (r) => setPlaceholderId(r.instance_id),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start. Try again."),
|
||||
});
|
||||
|
||||
const claim = useMutation({
|
||||
mutationFn: () => api.claimLink(placeholderId!, uuid.trim()),
|
||||
onSuccess: () => router.push("/"),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link. Try again."),
|
||||
});
|
||||
|
||||
const accountId = account.data?.account.account_id ?? "";
|
||||
const items =
|
||||
options.data && placeholderId
|
||||
? lineItemsFor(options.data, choice, "self_hosted")
|
||||
: [];
|
||||
|
||||
// Step 1 — name.
|
||||
if (!placeholderId) {
|
||||
return (
|
||||
<form
|
||||
className="grid max-w-md gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (name.trim()) create.mutate();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label="Instance name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Northgate Systems"
|
||||
required
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Button type="submit" disabled={create.isPending || !name.trim()}>
|
||||
{create.isPending ? "Starting…" : "Continue"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2 + 3 — configure & pay, then link.
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-6">
|
||||
<section className="rounded-lg border border-rule bg-panel p-4">
|
||||
<h2 className="mb-3 text-[0.95rem] font-medium text-ink">Configure</h2>
|
||||
{options.data ? (
|
||||
<PlanConfigurator
|
||||
deployment="self_hosted"
|
||||
value={choice}
|
||||
plans={options.data.plans}
|
||||
catalogue={options.data.catalogue}
|
||||
onChange={setChoice}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading plans…</p>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<CheckoutButton
|
||||
items={items}
|
||||
customData={{ account_id: accountId, instance_id: placeholderId }}
|
||||
disabled={!accountId || items.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-rule bg-panel p-4">
|
||||
<h2 className="mb-1 text-[0.95rem] font-medium text-ink">Link your install</h2>
|
||||
<p className="mb-3 text-[0.82rem] text-ink-3">
|
||||
After payment, paste the instance ID your Vantage install reports (Settings →
|
||||
Licence). Your licence is issued the moment it is linked.
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
<Field
|
||||
label="Instance ID"
|
||||
value={uuid}
|
||||
onChange={(e) => setUuid(e.target.value)}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={claim.isPending || !uuid.trim()}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
claim.mutate();
|
||||
}}
|
||||
>
|
||||
{claim.isPending ? "Linking…" : "Link and issue licence"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PurchaseForm } from "./PurchaseForm";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export const metadata: Metadata = { title: "Buy a self-hosted plan" };
|
||||
|
||||
export default function PurchasePage() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Self-hosted plan"
|
||||
subtitle="Name your instance, choose a plan, and pay. After payment, paste the ID your install reports to receive its licence. Self-hosted is billed annually."
|
||||
/>
|
||||
<PurchaseForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { initPaddle } from "@/lib/paddle";
|
||||
|
||||
/* Opens the Paddle overlay with the resolved line items and custom_data. The
|
||||
* items come from the configurator via catalogue pricing; custom_data is what
|
||||
* lets the webhook route without a lookup table. */
|
||||
export function CheckoutButton({
|
||||
items,
|
||||
customData,
|
||||
disabled,
|
||||
label = "Continue to payment",
|
||||
}: {
|
||||
items: { priceId: string; quantity: number }[];
|
||||
customData: { account_id: string; instance_id: string };
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function open() {
|
||||
setBusy(true);
|
||||
const paddle = await initPaddle();
|
||||
setBusy(false);
|
||||
paddle?.Checkout.open({
|
||||
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
|
||||
customData,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy || items.length === 0}
|
||||
onClick={open}
|
||||
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
{busy ? "Opening…" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import clsx from "clsx";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Instance, type License } from "@/lib/api";
|
||||
import { ManageBillingButton } from "@/components/ManageBillingButton";
|
||||
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { StatePill } from "./StatePill";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
@@ -252,6 +253,8 @@ export function InstanceRecord({
|
||||
{renew.isPending ? "Renewing…" : "Renew"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{cloud && <ManageBillingButton />}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
/* Opens Paddle's hosted customer portal in a new tab. The account learns its
|
||||
* paddle_customer_id from its first paid subscription's webhook, so this reports
|
||||
* a plain message rather than erroring when there is no billing account yet. */
|
||||
export function ManageBillingButton() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
async function open() {
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const { url } = await api.billingPortal();
|
||||
window.open(url, "_blank", "noopener");
|
||||
} catch (e) {
|
||||
setNote(e instanceof ApiError ? e.message : "Could not open billing.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Button type="button" variant="line" onClick={open} disabled={busy}>
|
||||
{busy ? "Opening…" : "Manage billing"}
|
||||
</Button>
|
||||
{note && <span className="text-[0.78rem] text-ink-3">{note}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -188,6 +188,51 @@ export interface EntitlementConfig {
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface CheckoutOptions {
|
||||
plans: Plan[];
|
||||
catalogue: CatalogueRow[];
|
||||
env: "sandbox" | "production";
|
||||
}
|
||||
|
||||
/*
|
||||
* lineItemsFor builds the Paddle checkout items for a configuration, client-side
|
||||
* from the catalogue already fetched. It mirrors the Go catalogue.LineItems and
|
||||
* its billable() exactly: base is quantity 1; the per-server unit's quantity is
|
||||
* servers MINUS the plan's base allowance (never charge for the base — the one
|
||||
* subtraction, kept here to match the server); a feature contributes an item
|
||||
* only when its row has a price in this environment/term.
|
||||
*/
|
||||
export function lineItemsFor(
|
||||
opts: CheckoutOptions,
|
||||
choice: { tier: Tier; term: Term; servers: number; features: string[] },
|
||||
deployment: Deployment,
|
||||
): { priceId: string; quantity: number }[] {
|
||||
const env = opts.env;
|
||||
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
|
||||
if (!plan) return [];
|
||||
const rows = opts.catalogue.filter(
|
||||
(r) => r.deployment === deployment && r.tier === choice.tier,
|
||||
);
|
||||
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
|
||||
const base = plan.base_limits.max_servers;
|
||||
const items: { priceId: string; quantity: number }[] = [];
|
||||
for (const r of rows) {
|
||||
const id = priceOf(r);
|
||||
if (r.kind === "base") {
|
||||
if (id) items.push({ priceId: id, quantity: 1 });
|
||||
} else if (r.kind === "limit" && r.limit_key === "max_servers") {
|
||||
// -1 base is unlimited: nothing metered. Otherwise charge servers over base.
|
||||
const qty = base === -1 ? 0 : choice.servers - base;
|
||||
if (qty > 0 && id) items.push({ priceId: id, quantity: qty });
|
||||
} else if (r.kind === "feature" && r.feature_key) {
|
||||
if (choice.features.includes(r.feature_key) && id) {
|
||||
items.push({ priceId: id, quantity: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export interface Entitlement {
|
||||
instance_id: string;
|
||||
account_id: string;
|
||||
@@ -269,6 +314,20 @@ export const api = {
|
||||
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
|
||||
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
|
||||
|
||||
entitlement: (id: string) =>
|
||||
req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`),
|
||||
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
|
||||
createSelfHosted: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
|
||||
updateEntitlement: (
|
||||
id: string,
|
||||
body: { tier: Tier; term: Term; servers: number; features: string[] },
|
||||
) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
|
||||
claimLink: (placeholderId: string, instance_id: string) =>
|
||||
post<{ instance_id: string; warning?: string }>(
|
||||
`/api/instances/${placeholderId}/claim-link`, { instance_id }),
|
||||
billingPortal: () => post<{ url: string }>("/api/billing/portal"),
|
||||
|
||||
accountUsers: () => req<AccountUser[]>("/api/account/users"),
|
||||
invite: (email: string, role: AccountRole) =>
|
||||
post<{ invited: boolean }>("/api/account/users", { email, role }),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { initializePaddle, type Paddle } from "@paddle/paddle-js";
|
||||
|
||||
let cached: Promise<Paddle | undefined> | null = null;
|
||||
|
||||
/* One Paddle instance for the app. The token and environment are baked into the
|
||||
* build (NEXT_PUBLIC_*), never fetched, so a production build can never load a
|
||||
* sandbox token by accident. */
|
||||
export function initPaddle(): Promise<Paddle | undefined> {
|
||||
if (!cached) {
|
||||
cached = initializePaddle({
|
||||
environment:
|
||||
(process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox",
|
||||
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "",
|
||||
});
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "vantage-adminsite",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@paddle/paddle-js": "^1.6.4",
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.2.9",
|
||||
@@ -1309,6 +1310,12 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paddle/paddle-js": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@paddle/paddle-js/-/paddle-js-1.6.4.tgz",
|
||||
"integrity": "sha512-ncfnS6I8mCX6krZ3Sgz2iAYivGmhdI81yt9mT6prtPj4Ipd9J3M12LCJRUFL4FB7BYeeuV04c33RSEnbZUBCaA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paddle/paddle-js": "^1.6.4",
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.2.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
"clsx": "^2.1.1"
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.11",
|
||||
|
||||
@@ -327,6 +327,7 @@ POST /auth/staff/login /auth/login /auth/logout
|
||||
POST /auth/signup # self-hosted only; honeypot + rate limited
|
||||
GET /auth/verify?token=…
|
||||
POST /auth/accept-invite # an invitee sets their own password
|
||||
POST /api/paddle/webhook # Paddle events; signature-verified, idempotent, no session
|
||||
```
|
||||
|
||||
Customer-session (`/api`), every instance resolved through `ownedInstance`:
|
||||
@@ -338,6 +339,11 @@ POST /instances/:id/renew # Free renewal; refuses outside t
|
||||
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
|
||||
POST /instances/link · /instances/:id/relink
|
||||
GET /instances/:id/entitlement
|
||||
GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV
|
||||
POST /instances/self-hosted # create a paid-checkout placeholder (awaiting_link, no licence)
|
||||
POST /instances/:id/claim-link # bind a paid placeholder to the real UUID and issue
|
||||
PUT /instances/:id/entitlement # set desired config; pushes line items to Paddle (owner|admin)
|
||||
POST /billing/portal # mint a Paddle customer-portal URL
|
||||
GET /instances/:id/license · /instances/:id/license/download
|
||||
GET /subscriptions
|
||||
GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id
|
||||
@@ -360,11 +366,17 @@ POST /instances/:id/issue · /instances/:id/relink
|
||||
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
|
||||
GET,PUT /catalogue
|
||||
GET,PUT /instances/:id/entitlement
|
||||
GET /health/injection
|
||||
GET /health/injection · /health/billing
|
||||
```
|
||||
|
||||
**Customer endpoints answer 404, never 403, for another account's resource** — a 403 confirms the resource exists. Route-group guards in `adminsite/` mirror this, but the backend is the layer that matters.
|
||||
|
||||
### Billing (Paddle)
|
||||
|
||||
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
|
||||
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's *current* line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
@@ -381,7 +393,7 @@ Notes that are not obvious from the structs:
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
|
||||
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
|
||||
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
|
||||
|
||||
@@ -613,6 +625,10 @@ git push origin main # server + web deploy
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
|
||||
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
|
||||
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
|
||||
| `PADDLE_CLIENT_TOKEN`| Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
|
||||
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
|
||||
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ services:
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
|
||||
FREE_INSTANCE_REAP_AFTER: "336h"
|
||||
# Paddle billing. PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are
|
||||
# boot-REQUIRED — an unverified webhook endpoint is one anyone can
|
||||
# issue licences through. PADDLE_ENV selects which catalogue price
|
||||
# IDs are served (sandbox|production).
|
||||
PADDLE_ENV: ${PADDLE_ENV:-sandbox}
|
||||
PADDLE_API_KEY: ${PADDLE_API_KEY:-}
|
||||
PADDLE_WEBHOOK_SECRET: ${PADDLE_WEBHOOK_SECRET:-}
|
||||
|
||||
# The staff and customer console, served at vantage-hq.hostxtra.co.uk.
|
||||
# ADMIN_API_URL is baked into the image at build time, not read here, so
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ Build in this order. Specs 0a–5 were designed 2026-07-24; spec 6 on 2026-07-26
|
||||
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
|
||||
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases |
|
||||
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-26-paddle-billing.md) | ready to start, **but revised by 7** — its "signup migration off sitesvc" section is superseded by 6, and its single-price-per-subscription assumption by 7 |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-27-paddle-billing.md) | **shipped (code)** — client, webhooks, checkout, entitlement update and portal built and compiled against spec-7's catalogue/entitlements; signup-migration dropped (done by 6). Live sandbox catalog + end-to-end pass is the operator's step. Old [2026-07-26 plan](../plans/2026-07-26-paddle-billing.md) superseded. |
|
||||
| 6 | [cloud-instance-creation](2026-07-26-cloud-instance-creation-design.md) | — | ready to start |
|
||||
| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | [plan](../plans/2026-07-26-metered-licensing.md) | **shipped** — staff can configure and issue any of the six plans; no customer can buy one until 5 lands |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user