diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index 5d71a11..2619149 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -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 { diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index dfe1f49..e647bc0 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -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) diff --git a/admin/internal/mail/mail.go b/admin/internal/mail/mail.go index 14da8e3..043bd7b 100644 --- a/admin/internal/mail/mail.go +++ b/admin/internal/mail/mail.go @@ -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"))) +} diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index e13eb02..1234277 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -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"`