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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user