diff --git a/admin/internal/api/checkout.go b/admin/internal/api/checkout.go index 9c0cadf..e49f30f 100644 --- a/admin/internal/api/checkout.go +++ b/admin/internal/api/checkout.go @@ -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) { diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index 953f25c..7800d11 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -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 diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 33718ab..3a35b5d 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -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 { diff --git a/admin/internal/lifecycle/lifecycle.go b/admin/internal/lifecycle/lifecycle.go index e2fe31d..0259a0d 100644 --- a/admin/internal/lifecycle/lifecycle.go +++ b/admin/internal/lifecycle/lifecycle.go @@ -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 }