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:
@@ -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(¤t); 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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user