feat(admin): awaiting-link reminders, placeholder claim-and-issue, and billing health

sweepAwaitingLink chases paid-but-unlinked self-hosted placeholders at 24h/72h.
claimPlaceholderLink rewrites a placeholder's identity to the install's real
UUID, re-points the subscription, and issues from it — linking and claiming are
one call because the payment already happened. staffBillingHealth surfaces
failed webhook handlers and unlinked paid placeholders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:46:00 +01:00
co-authored by Claude Opus 5
parent 28b138b4c3
commit c10f093cad
4 changed files with 168 additions and 0 deletions
+71
View File
@@ -9,6 +9,7 @@ import (
"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"
@@ -161,6 +162,76 @@ func updateEntitlement(c *gin.Context) {
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) {
+4
View File
@@ -84,6 +84,9 @@ func Routes(cfg config.Config) http.Handler {
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)
@@ -125,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
+23
View File
@@ -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 {