feat(admin): renew a Free instance from the portal

Available from seven days before expiry and, deliberately, at any point
after it up to deletion, so the same button rescues a lapsed instance.

Renewal is manual because it is the entire reclaim signal: an instance
nobody renews is one nobody is using, which is what makes reaping safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 13:17:45 +01:00
co-authored by Claude Opus 5
parent 1836237f82
commit 909ddb884e
4 changed files with 81 additions and 0 deletions
+66
View File
@@ -308,6 +308,72 @@ func createInstance(c *gin.Context) {
c.JSON(http.StatusCreated, rec)
}
// renewInstance extends a Free licence by another term.
//
// Renewal is manual on purpose: it is the entire reclaim signal. An instance
// nobody renews is an instance nobody is using, and that is what makes the
// reaper safe to run at all.
func renewInstance(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if inst.Tier != license.TierFree {
c.JSON(http.StatusBadRequest, gin.H{
"error": "only Free instances renew here; paid plans renew through billing"})
return
}
ctx := c.Request.Context()
var current models.License
if err := db.Admin("licenses").FindOne(ctx,
bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
return
}
if time.Now().UTC().Before(current.ExpiresAt.Add(-models.RenewWindow)) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("this licence is not due yet; you can renew from %s",
current.ExpiresAt.Add(-models.RenewWindow).Format("2 January 2006"))})
return
}
lic, err := licensing.Issue(ctx, licensing.IssueInput{
InstanceID: inst.InstanceID,
Tier: license.TierFree,
Term: "monthly",
Reason: models.ReasonRenewal,
IssuedBy: "self-serve",
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
inject.Deliver(ctx, lic)
// Clear the notice log so the next term starts the sequence again. Issue has
// already set status back to active.
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$unset": bson.M{"notices_sent": ""}}); err != nil {
log.Printf("renewInstance: clear notices for %s: %v", inst.InstanceID, err)
}
s := auth.Current(c)
audit.Write(ctx, models.AuditEntry{
Actor: s.Email, Action: "instance.renewed", AccountID: s.AccountID,
Target: inst.InstanceID, IP: c.ClientIP()})
if mail.Enabled() {
if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
}
}
c.JSON(http.StatusOK, lic)
}
// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template
// yields an empty string, and the email simply omits the link.
func loginURLFor(slug string) string {
+1
View File
@@ -44,6 +44,7 @@ func Routes(cfg config.Config) http.Handler {
cust.POST("/instances", createInstance)
cust.POST("/instances/link", linkInstance)
cust.POST("/instances/:id/relink", relinkInstance)
cust.POST("/instances/:id/renew", renewInstance)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/subscriptions", listSubscriptions)
+7
View File
@@ -73,3 +73,10 @@ func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) err
expires.Format("2 January 2006"))
return send(to, instanceName+" is ready", body)
}
// SendRenewed confirms a renewal and states the new date.
func SendRenewed(to, instanceName string, expires time.Time) error {
return send(to, instanceName+" renewed",
fmt.Sprintf("%s is renewed.\n\nYour Free licence now runs until %s.\n",
instanceName, expires.Format("2 January 2006")))
}
+7
View File
@@ -53,6 +53,13 @@ const MaxRelinksPerTerm = 3
// paying customer's instance goes read-only.
const GracePeriod = 3 * 24 * time.Hour
// RenewWindow is how long before expiry a Free instance may be renewed.
//
// Renewal stays available after expiry too, right up until the reaper takes the
// instance, so the same button rescues a lapsed instance instead of needing a
// second mechanism.
const RenewWindow = 7 * 24 * time.Hour
type Account struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
AccountID string `bson:"account_id" json:"account_id"`