diff --git a/admin-web/index.html b/admin-web/index.html
index fce0452..20b7b52 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -1413,6 +1413,7 @@
@@ -1422,6 +1423,9 @@
+
Подписка:
+
+
diff --git a/backend/cmd/server/server.exe b/backend/cmd/server/server.exe
index fb75048..ed92dde 100644
Binary files a/backend/cmd/server/server.exe and b/backend/cmd/server/server.exe differ
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index 3959032..4fefc80 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"log/slog"
"net/http"
"runtime"
@@ -1197,6 +1198,108 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
writeHeroJSON(w, http.StatusOK, hero)
}
+type grantSubscriptionRequest struct {
+ // Periods is how many subscription durations to add (stacking extends from current expiry). Default 1.
+ Periods int `json:"periods"`
+}
+
+// GrantHeroSubscription activates or extends subscription like a purchase, without charging RUB (admin grant).
+// POST /admin/heroes/{heroId}/grant-subscription
+func (h *AdminHandler) GrantHeroSubscription(w http.ResponseWriter, r *http.Request) {
+ heroID, err := parseHeroID(r)
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "invalid heroId: " + err.Error(),
+ })
+ return
+ }
+ if h.isHeroInCombat(w, heroID) {
+ return
+ }
+
+ var req grantSubscriptionRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "invalid request body: " + err.Error(),
+ })
+ return
+ }
+ periods := req.Periods
+ if periods < 1 {
+ periods = 1
+ }
+ if periods > 52 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "periods must be between 1 and 52",
+ })
+ return
+ }
+
+ hero, err := h.store.GetByID(r.Context(), heroID)
+ if err != nil {
+ h.logger.Error("admin: get hero for grant-subscription", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "failed to load hero",
+ })
+ return
+ }
+ if hero == nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{
+ "error": "hero not found",
+ })
+ return
+ }
+
+ now := time.Now()
+ for i := 0; i < periods; i++ {
+ hero.ActivateSubscription(now)
+ }
+
+ hero.EnsureBuffChargesPopulated(now)
+ for bt := range model.BuffFreeChargesPerType {
+ state := hero.GetBuffCharges(bt, now)
+ subMax := hero.MaxBuffCharges(bt)
+ if state.Remaining < subMax {
+ state.Remaining = subMax
+ hero.BuffCharges[string(bt)] = state
+ }
+ }
+
+ payment := &model.Payment{
+ HeroID: hero.ID,
+ Type: model.PaymentType("subscription_admin"),
+ AmountRUB: 0,
+ Status: model.PaymentCompleted,
+ CreatedAt: now,
+ CompletedAt: &now,
+ }
+ if err := h.store.CreatePayment(r.Context(), payment); err != nil {
+ h.logger.Error("admin: grant-subscription payment row", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "failed to record payment",
+ })
+ return
+ }
+
+ if err := h.store.Save(r.Context(), hero); err != nil {
+ h.logger.Error("admin: save hero after grant-subscription", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "failed to save hero",
+ })
+ return
+ }
+
+ h.logger.Info("admin: subscription granted",
+ "hero_id", heroID,
+ "periods", periods,
+ "expires_at", hero.SubscriptionExpiresAt,
+ )
+ hero.EnsureGearMap()
+ hero.RefreshDerivedCombatStats(now)
+ h.engine.ApplyAdminHeroSnapshot(hero)
+ writeHeroJSON(w, http.StatusOK, hero)
+}
+
// ForceHeroDeath sets the hero to dead (HP 0, state dead), ends active combat, clears buffs/debuffs,
// and increments death stats when transitioning from alive.
// POST /admin/heroes/{heroId}/force-death
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index fb13072..c7412fa 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -82,6 +82,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/set-hp", adminH.SetHeroHP)
r.Post("/heroes/{heroId}/add-potions", adminH.AddPotions)
r.Post("/heroes/{heroId}/revive", adminH.ReviveHero)
+ r.Post("/heroes/{heroId}/grant-subscription", adminH.GrantHeroSubscription)
r.Post("/heroes/{heroId}/force-death", adminH.ForceHeroDeath)
r.Post("/heroes/{heroId}/reset", adminH.ResetHero)
r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges)