Просмотр исходного кода

feat(clients): cap how many times a client may auto-renew (#6238)

* feat(clients): cap how many times a client may auto-renew

Auto-renew today runs forever: a prepaid or fixed-term client keeps being
handed new periods until an operator remembers to switch it off. There is no
way to say "renew this three times, then let it lapse".

Add a per-client maximum. Zero keeps today's behaviour, so nothing changes for
anyone who does not set one. When the count is reached the client is simply
left to expire, like any client without auto-renew.

Catching up several missed periods spends one allowance per period. A client
that was away for three cycles must not receive three of them free of the cap,
and the catch-up stops at the last period the cap paid for rather than jumping
to the present.

* fix(clients): persist the auto-renew cap and stop the capped churn

resetMax lived only in the inbound settings JSON and client_traffics, so
every path that rebuilds a client from the clients table wrote it back as
zero. The edit dialog showed 0 for a capped client, and saving an
unrelated comment change lifted the cap; an attach or a traffic reset did
the same with no operator action at all.

Adds reset_max to ClientRecord and threads it through ToRecord, ToClient,
applyClientRecordMerge, the record update map and ClientSlim, so the cap
survives the round trip.

When the cap truncates a catch-up the client is still expired, but the
renewal side effects fired anyway: counters were zeroed for periods it
can never use, and it was enabled and pushed to xray only for
disableInvalidClients to undo both in the same transaction. Those are now
skipped when the new expiry has not reached the present.

Also makes any non-positive resetMax mean unlimited instead of silently
meaning "never renew again", rejects a negative one at the service layer,
surfaces renewals used against allowed in the client info modal so the
operator can see what to raise, adds the field to the bulk-add modal,
translates the labels in all 13 locales, and drops the stray
internal/web/dist/.gitkeep build stub.

* fix(clients): let the renewal cap be changed after creation

ClientService.Update writes the record columns directly only for a client
with no inbounds. The normal path goes through SyncInbound and
applyClientRecordMerge, which this change had not extended, so raising a
cap from 3 to 6 — the natural action when a customer buys another block
of periods — updated the inbound settings JSON while clients.reset_max
kept the old value and the renewal query kept enforcing it.

The existing test did not catch it: it asserted the cap survived an
unrelated edit, and it survived precisely because nothing on that path
ever wrote it. TestClientEditChangesTheRenewalCap raises the cap and then
lifts it entirely; removing the record write turns it red.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <[email protected]>
n0ctal 14 часов назад
Родитель
Сommit
e940f30bb8

+ 25 - 0
frontend/public/openapi.json

@@ -1110,6 +1110,10 @@
             "description": "Reset period in days",
             "type": "integer"
           },
+          "resetMax": {
+            "description": "Max auto-renew count, 0 = unlimited",
+            "type": "integer"
+          },
           "reverse": {
             "allOf": [
               {
@@ -1154,6 +1158,7 @@
           "expiryTime",
           "limitIp",
           "reset",
+          "resetMax",
           "security",
           "subId",
           "tgId",
@@ -1246,6 +1251,9 @@
           "reset": {
             "type": "integer"
           },
+          "resetMax": {
+            "type": "integer"
+          },
           "reverse": {},
           "secret": {
             "type": "string"
@@ -1292,6 +1300,7 @@
           "privateKey",
           "publicKey",
           "reset",
+          "resetMax",
           "reverse",
           "secret",
           "security",
@@ -1357,6 +1366,16 @@
             "example": 0,
             "type": "integer"
           },
+          "resetCount": {
+            "description": "ResetCount is how many have fired, so a prepaid plan stops on its own.",
+            "example": 0,
+            "type": "integer"
+          },
+          "resetMax": {
+            "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
+            "example": 0,
+            "type": "integer"
+          },
           "subId": {
             "example": "i7tvdpeffi0hvvf1",
             "type": "string"
@@ -1386,6 +1405,8 @@
           "lastOnline",
           "lastSubFetch",
           "reset",
+          "resetCount",
+          "resetMax",
           "subId",
           "total",
           "up",
@@ -3327,6 +3348,8 @@
                           "lastOnline": 1735680000000,
                           "lastSubFetch": 1735680000000,
                           "reset": 0,
+                          "resetCount": 0,
+                          "resetMax": 0,
                           "subId": "i7tvdpeffi0hvvf1",
                           "total": 10737418240,
                           "up": 1048576,
@@ -8130,6 +8153,8 @@
                     "lastOnline": 1735680000000,
                     "lastSubFetch": 1735680000000,
                     "reset": 0,
+                    "resetCount": 0,
+                    "resetMax": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
                     "up": 1048576,

+ 6 - 0
frontend/src/generated/examples.ts

@@ -256,6 +256,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "privateKey": "",
     "publicKey": "",
     "reset": 0,
+    "resetMax": 0,
     "reverse": null,
     "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
     "security": "",
@@ -290,6 +291,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "privateKey": "",
     "publicKey": "",
     "reset": 0,
+    "resetMax": 0,
     "reverse": null,
     "secret": "",
     "security": "",
@@ -312,6 +314,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "lastOnline": 1735680000000,
     "lastSubFetch": 1735680000000,
     "reset": 0,
+    "resetCount": 0,
+    "resetMax": 0,
     "subId": "i7tvdpeffi0hvvf1",
     "total": 10737418240,
     "up": 1048576,
@@ -478,6 +482,8 @@ export const EXAMPLES: Record<string, unknown> = {
         "lastOnline": 1735680000000,
         "lastSubFetch": 1735680000000,
         "reset": 0,
+        "resetCount": 0,
+        "resetMax": 0,
         "subId": "i7tvdpeffi0hvvf1",
         "total": 10737418240,
         "up": 1048576,

+ 21 - 0
frontend/src/generated/schemas.ts

@@ -1084,6 +1084,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Reset period in days",
         "type": "integer"
       },
+      "resetMax": {
+        "description": "Max auto-renew count, 0 = unlimited",
+        "type": "integer"
+      },
       "reverse": {
         "allOf": [
           {
@@ -1128,6 +1132,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expiryTime",
       "limitIp",
       "reset",
+      "resetMax",
       "security",
       "subId",
       "tgId",
@@ -1220,6 +1225,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "reset": {
         "type": "integer"
       },
+      "resetMax": {
+        "type": "integer"
+      },
       "reverse": {},
       "secret": {
         "type": "string"
@@ -1266,6 +1274,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "privateKey",
       "publicKey",
       "reset",
+      "resetMax",
       "reverse",
       "secret",
       "security",
@@ -1331,6 +1340,16 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 0,
         "type": "integer"
       },
+      "resetCount": {
+        "description": "ResetCount is how many have fired, so a prepaid plan stops on its own.",
+        "example": 0,
+        "type": "integer"
+      },
+      "resetMax": {
+        "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
+        "example": 0,
+        "type": "integer"
+      },
       "subId": {
         "example": "i7tvdpeffi0hvvf1",
         "type": "string"
@@ -1360,6 +1379,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "lastOnline",
       "lastSubFetch",
       "reset",
+      "resetCount",
+      "resetMax",
       "subId",
       "total",
       "up",

+ 4 - 0
frontend/src/generated/types.ts

@@ -266,6 +266,7 @@ export interface Client {
   privateKey?: string;
   publicKey?: string;
   reset: number;
+  resetMax: number;
   reverse?: ClientReverse | null;
   secret?: string;
   security: string;
@@ -302,6 +303,7 @@ export interface ClientRecord {
   privateKey: string;
   publicKey: string;
   reset: number;
+  resetMax: number;
   reverse: unknown;
   secret: string;
   security: string;
@@ -326,6 +328,8 @@ export interface ClientTraffic {
   lastOnline: number;
   lastSubFetch: number;
   reset: number;
+  resetCount: number;
+  resetMax: number;
   subId: string;
   total: number;
   up: number;

+ 4 - 0
frontend/src/generated/zod.ts

@@ -286,6 +286,7 @@ export const ClientSchema = z.object({
   privateKey: z.string().optional(),
   publicKey: z.string().optional(),
   reset: z.number().int(),
+  resetMax: z.number().int(),
   reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
   secret: z.string().optional(),
   security: z.string(),
@@ -324,6 +325,7 @@ export const ClientRecordSchema = z.object({
   privateKey: z.string(),
   publicKey: z.string(),
   reset: z.number().int(),
+  resetMax: z.number().int(),
   reverse: z.unknown(),
   secret: z.string(),
   security: z.string(),
@@ -350,6 +352,8 @@ export const ClientTrafficSchema = z.object({
   lastOnline: z.number().int(),
   lastSubFetch: z.number().int(),
   reset: z.number().int(),
+  resetCount: z.number().int(),
+  resetMax: z.number().int(),
   subId: z.string(),
   total: z.number().int(),
   up: z.number().int(),

+ 11 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -37,6 +37,7 @@ const EMPTY: ClientBulkAddFormValues = {
   totalGB: 0,
   expiryTime: 0,
   reset: 0,
+  resetMax: 0,
   inboundIds: [],
 };
 
@@ -176,6 +177,7 @@ export default function ClientBulkAddModal({
           totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
           expiryTime: current.expiryTime,
           reset: Number(current.reset) || 0,
+          resetMax: Number(current.resetMax) || 0,
           limitIp: Number(current.limitIp) || 0,
           limitHwid: Number(current.limitHwid) || 0,
           group: current.group,
@@ -374,6 +376,15 @@ export default function ClientBulkAddModal({
             >
               <InputNumber min={0} />
             </FormField>
+
+            <FormField
+              name="resetMax"
+              label={t('pages.clients.renewMax')}
+              tooltip={t('pages.clients.renewMaxDesc')}
+              transform={{ output: (v) => Number(v) || 0 }}
+            >
+              <InputNumber min={0} />
+            </FormField>
           </Form>
         </FormProvider>
       </Modal>

+ 14 - 0
frontend/src/pages/clients/ClientFormModal.tsx

@@ -131,6 +131,7 @@ const EMPTY: Values = {
   delayedStart: false,
   delayedDays: 0,
   reset: 0,
+  resetMax: 0,
   limitIp: 0,
   limitHwid: 0,
   tgId: 0,
@@ -250,6 +251,7 @@ export default function ClientFormModal({
         reverseTag: client.reverse?.tag || '',
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
+        resetMax: Number(client.resetMax) || 0,
         limitIp: client.limitIp || 0,
         limitHwid: client.limitHwid || 0,
         tgId: Number(client.tgId) || 0,
@@ -538,6 +540,7 @@ email: values.email,
       delayedStart: values.delayedStart,
       delayedDays: values.delayedDays,
       reset: values.reset,
+      resetMax: values.resetMax,
       limitIp: values.limitIp,
       limitHwid: values.limitHwid,
       tgId: values.tgId,
@@ -566,6 +569,7 @@ email: values.email,
       totalGB: totalBytes,
       expiryTime,
 reset: Number(values.reset) || 0,
+      resetMax: Number(values.resetMax) || 0,
       limitIp: Number(values.limitIp) || 0,
       limitHwid: Number(values.limitHwid) || 0,
       tgId: Number(values.tgId) || 0,
@@ -785,6 +789,16 @@ reset: Number(values.reset) || 0,
                             <InputNumber min={0} style={{ width: '100%' }} />
                           </FormField>
                         </Col>
+                        <Col xs={12} md={6}>
+                          <FormField
+                            name="resetMax"
+                            label={t('pages.clients.renewMax')}
+                            tooltip={t('pages.clients.renewMaxDesc')}
+                            transform={{ output: (v) => Number(v) || 0 }}
+                          >
+                            <InputNumber min={0} style={{ width: '100%' }} />
+                          </FormField>
+                        </Col>
                       </Row>
 
                       <Row gutter={16}>

+ 10 - 0
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -325,6 +325,16 @@ export default function ClientInfoModal({
                     </Button>
                   </td>
                 </tr>
+                {(traffic?.resetMax ?? 0) > 0 && (
+                  <tr>
+                    <td>{t('pages.clients.renewsUsed')}</td>
+                    <td>
+                      <Tag color={(traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'}>
+                        {traffic?.resetCount ?? 0} / {traffic?.resetMax}
+                      </Tag>
+                    </td>
+                  </tr>
+                )}
                 <tr>
                   <td>{t('pages.inbounds.createdAt')}</td>
                   <td><Tag>{dateLabel(client.createdAt)}</Tag></td>

+ 5 - 0
frontend/src/schemas/client.ts

@@ -11,6 +11,8 @@ export const ClientTrafficSchema = z.object({
   enable: z.boolean().optional(),
   lastOnline: z.number().optional(),
   lastSubFetch: z.number().optional(),
+  resetMax: z.number().optional(),
+  resetCount: z.number().optional(),
 });
 
 export const ClientRecordSchema = z.object({
@@ -31,6 +33,7 @@ export const ClientRecordSchema = z.object({
   comment: z.string().optional(),
   enable: z.boolean().optional(),
   reset: z.number().optional(),
+  resetMax: z.number().optional(),
   inboundIds: nullableNumberArray.optional(),
   traffic: ClientTrafficSchema.nullable().optional(),
   reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(),
@@ -205,6 +208,7 @@ export const ClientFormSchema = z.object({
   delayedStart: z.boolean(),
   delayedDays: z.number().int().min(0),
   reset: z.number().int().min(0),
+  resetMax: z.number().int().min(0),
   limitIp: z.number().int().min(0),
   limitHwid: z.number().int().min(0),
   tgId: z.number().int().min(0),
@@ -244,6 +248,7 @@ export const ClientBulkAddFormSchema = z.object({
   totalGB: z.number().min(0),
   expiryTime: z.number(),
   reset: z.number().int().min(0),
+  resetMax: z.number().int().min(0),
   inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
 });
 

+ 10 - 0
internal/database/model/model.go

@@ -894,6 +894,7 @@ type Client struct {
 	Group        string         `json:"group,omitempty" form:"group"` // Logical grouping label
 	Comment      string         `json:"comment" form:"comment"`       // Client comment
 	Reset        int            `json:"reset" form:"reset"`           // Reset period in days
+	ResetMax     int            `json:"resetMax" form:"resetMax"`     // Max auto-renew count, 0 = unlimited
 	CreatedAt    int64          `json:"created_at,omitempty"`         // Creation timestamp
 	UpdatedAt    int64          `json:"updated_at,omitempty"`         // Last update timestamp
 }
@@ -924,6 +925,7 @@ type ClientRecord struct {
 	Group        string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
 	Comment      string `json:"comment"`
 	Reset        int    `json:"reset" gorm:"default:0"`
+	ResetMax     int    `json:"resetMax" gorm:"column:reset_max;default:0"`
 	CreatedAt    int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 	UpdatedAt    int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 	// Owned solely by the node-snapshot sweep, which soft-orphans instead of
@@ -1105,6 +1107,7 @@ func (c *Client) ToRecord() *ClientRecord {
 		Group:      c.Group,
 		Comment:    c.Comment,
 		Reset:      c.Reset,
+		ResetMax:   c.ResetMax,
 		CreatedAt:  c.CreatedAt,
 		UpdatedAt:  c.UpdatedAt,
 
@@ -1158,6 +1161,7 @@ func (r *ClientRecord) ToClient() *Client {
 		Group:      r.Group,
 		Comment:    r.Comment,
 		Reset:      r.Reset,
+		ResetMax:   r.ResetMax,
 		CreatedAt:  r.CreatedAt,
 		UpdatedAt:  r.UpdatedAt,
 
@@ -1306,6 +1310,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.Reset = incoming.Reset
 		}
 	}
+	if existing.ResetMax != incoming.ResetMax && incoming.ResetMax != 0 {
+		if incomingNewer || existing.ResetMax == 0 {
+			keep("resetMax", existing.ResetMax, incoming.ResetMax, incoming.ResetMax)
+			existing.ResetMax = incoming.ResetMax
+		}
+	}
 	if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
 		if incomingNewer || existing.Reverse == "" {
 			keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)

+ 16 - 0
internal/web/service/client_crud.go

@@ -43,6 +43,15 @@ func validateClientSubID(subID string) error {
 	return nil
 }
 
+// Rejected rather than coerced: a negative cap reads as "unlimited" to a caller
+// but selects nothing, so the client would silently stop renewing.
+func validateClientResetMax(resetMax int) error {
+	if resetMax < 0 {
+		return common.NewError("client resetMax must not be negative, got:", resetMax)
+	}
+	return nil
+}
+
 func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
 	if payload == nil {
 		return false, common.NewError("empty payload")
@@ -57,6 +66,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 	if err := validateClientSubID(client.SubID); err != nil {
 		return false, err
 	}
+	if err := validateClientResetMax(client.ResetMax); err != nil {
+		return false, err
+	}
 	if len(payload.InboundIds) == 0 {
 		return false, common.NewError("at least one inbound is required")
 	}
@@ -344,6 +356,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	if err := validateClientSubID(updated.SubID); err != nil {
 		return false, err
 	}
+	if err := validateClientResetMax(updated.ResetMax); err != nil {
+		return false, err
+	}
 	if updated.SubID == "" {
 		updated.SubID = existing.SubID
 	}
@@ -466,6 +481,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 				"tg_id":             merged.TgID,
 				"comment":           merged.Comment,
 				"reset":             merged.Reset,
+				"reset_max":         merged.ResetMax,
 			}).Error; err != nil {
 			return needRestart, err
 		}

+ 1 - 0
internal/web/service/client_link.go

@@ -63,6 +63,7 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor
 	}
 	row.Comment = incoming.Comment
 	row.Reset = incoming.Reset
+	row.ResetMax = incoming.ResetMax
 	if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
 		row.CreatedAt = incoming.CreatedAt
 	}

+ 2 - 0
internal/web/service/client_paging.go

@@ -26,6 +26,7 @@ type ClientSlim struct {
 	LimitIP    int                 `json:"limitIp"`
 	LimitHwid  int                 `json:"limitHwid"`
 	Reset      int                 `json:"reset"`
+	ResetMax   int                 `json:"resetMax"`
 	Group      string              `json:"group,omitempty"`
 	Comment    string              `json:"comment,omitempty"`
 	InboundIds []int               `json:"inboundIds"`
@@ -605,6 +606,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim {
 		LimitIP:    c.LimitIP,
 		LimitHwid:  c.LimitHwid,
 		Reset:      c.Reset,
+		ResetMax:   c.ResetMax,
 		Group:      c.Group,
 		Comment:    c.Comment,
 		InboundIds: c.InboundIds,

+ 287 - 0
internal/web/service/inbound_autorenew_maxcount_test.go

@@ -0,0 +1,287 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// A prepaid plan must stop itself: once as many renewals have fired as the
+// operator allowed, the client expires like any other (#5804).
+func TestAutoRenewClients_StopsAtMaxCount(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "spent@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
+		{Email: "left@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30101, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	rows := []xray.ClientTraffic{
+		{InboundId: ib.Id, Email: "spent@x", Enable: false, Reset: 30, ResetMax: 2, ResetCount: 2, ExpiryTime: past},
+		{InboundId: ib.Id, Email: "left@x", Enable: false, Reset: 30, ResetMax: 2, ResetCount: 1, ExpiryTime: past},
+	}
+	if err := db.Create(&rows).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("renewed count = %d, want 1: only the client with an allowance left", count)
+	}
+
+	var spent xray.ClientTraffic
+	if err := db.Where("email = ?", "spent@x").First(&spent).Error; err != nil {
+		t.Fatal(err)
+	}
+	if spent.ExpiryTime != past {
+		t.Fatalf("a client that used its allowance was renewed anyway: expiry %d", spent.ExpiryTime)
+	}
+
+	var left xray.ClientTraffic
+	if err := db.Where("email = ?", "left@x").First(&left).Error; err != nil {
+		t.Fatal(err)
+	}
+	if left.ExpiryTime <= past {
+		t.Fatal("a client with an allowance left was not renewed")
+	}
+	if left.ResetCount != 2 {
+		t.Fatalf("reset count = %d after one renewal, want 2", left.ResetCount)
+	}
+}
+
+// Catching up several missed periods spends one allowance per period: a client
+// that was away for three cycles must not receive three of them for free.
+func TestAutoRenewClients_CatchUpSpendsOneAllowancePerPeriod(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	// Three whole 30-day periods behind.
+	past := time.Now().Add(-95 * 24 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "away@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30102, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "away@x", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "away@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.ResetCount != 2 {
+		t.Fatalf("reset count = %d, want the 2 the cap allowed", row.ResetCount)
+	}
+	// Two periods granted, three needed: the client stays expired rather than
+	// silently receiving the third.
+	want := past + 2*30*86400000
+	if row.ExpiryTime != want {
+		t.Fatalf("expiry = %d, want %d: exactly the periods the cap paid for", row.ExpiryTime, want)
+	}
+	if row.ExpiryTime > time.Now().UnixMilli() {
+		t.Fatal("the capped catch-up handed out a future expiry it had not paid for")
+	}
+}
+
+// No cap set is the existing behaviour: renew for as long as the client keeps
+// expiring.
+func TestAutoRenewClients_NoCapRenewsAsBefore(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "forever@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, Reset: 30, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30103, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "forever@x", Enable: false, Reset: 30, ResetCount: 99, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("renewed count = %d, want 1: a client without a cap keeps renewing", count)
+	}
+}
+
+// The cap has to survive the clients table, not just the settings JSON: an
+// ordinary edit rebuilds the client from the record and writes it back (#5804).
+func TestClientEditKeepsTheRenewalCap(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{Email: "cap@x", ID: "44444444-4444-4444-4444-444444444444", Enable: true, Reset: 30, ResetMax: 3, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()},
+	}
+	ib := mkInbound(t, 30104, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	mkTraffic(t, ib.Id, "cap@x", 10, 20, 0, 0, true)
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "cap@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.ResetMax != 3 {
+		t.Fatalf("clients.reset_max = %d, want the 3 the client was created with", rec.ResetMax)
+	}
+
+	// What the edit dialog does: hydrate the record, change something else, save.
+	edited := rec.ToClient()
+	edited.Comment = "renamed"
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	var stored model.Inbound
+	if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
+		t.Fatal(err)
+	}
+	var settings struct {
+		Clients []model.Client `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
+		t.Fatalf("parse inbound settings: %v", err)
+	}
+	if len(settings.Clients) != 1 {
+		t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
+	}
+	if settings.Clients[0].ResetMax != 3 {
+		t.Fatalf("inbound settings resetMax = %d after an unrelated edit, want 3: the cap was silently lifted", settings.Clients[0].ResetMax)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "cap@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.ResetMax != 3 {
+		t.Fatalf("clients.reset_max = %d after an unrelated edit, want 3", rec.ResetMax)
+	}
+}
+
+// A cap that runs out mid-catch-up leaves the client expired, so the renewal
+// side effects must not fire: disableInvalidClients would undo them at once.
+func TestAutoRenewClients_TruncatedCatchUpLeavesTheClientDisabled(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	// Five periods behind with one allowance left: one 30-day step cannot reach
+	// the present, so the client stays expired.
+	past := time.Now().Add(-150 * 24 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "short@x", ID: "55555555-5555-5555-5555-555555555555", Enable: false, Reset: 30, ResetMax: 3, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30105, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "short@x", Enable: false, Reset: 30, ResetMax: 3, ResetCount: 2,
+		Up: 111, Down: 222, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "short@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.ExpiryTime >= time.Now().UnixMilli() {
+		t.Fatalf("expiry %d reached the present: the cap did not truncate the catch-up", row.ExpiryTime)
+	}
+	if row.Enable {
+		t.Fatal("a client still expired after a truncated catch-up was enabled: xray gains a user only to lose it again")
+	}
+	if row.Up != 111 || row.Down != 222 {
+		t.Fatalf("counters zeroed for periods the client can never use: up=%d down=%d", row.Up, row.Down)
+	}
+}
+
+// The cap is useless if it can only be chosen once. The test above passes even
+// without the record write, because nothing overwrites the value it checks.
+func TestClientEditChangesTheRenewalCap(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+
+	clients := []model.Client{
+		{
+			Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, Reset: 30, ResetMax: 3,
+			ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
+		},
+	}
+	ib := mkInbound(t, 30106, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	mkTraffic(t, ib.Id, "chg@x", 0, 0, 0, 0, true)
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+
+	// The customer buys another block of periods, which is the whole point of
+	// the field being editable.
+	edited := rec.ToClient()
+	edited.ResetMax = 6
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.ResetMax != 6 {
+		t.Fatalf("clients.reset_max = %d after the operator raised the cap to 6", rec.ResetMax)
+	}
+
+	// Lifting the cap entirely has to work too.
+	edited = rec.ToClient()
+	edited.ResetMax = 0
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update to uncapped: %v", err)
+	}
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after lifting the cap: %v", err)
+	}
+	if rec.ResetMax != 0 {
+		t.Fatalf("clients.reset_max = %d after the operator lifted the cap", rec.ResetMax)
+	}
+}

+ 23 - 1
internal/web/service/inbound_traffic.go

@@ -334,6 +334,9 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 	// local inbounds. The email-based join through client_inbounds is authoritative.
 	err = tx.Model(xray.ClientTraffic{}).
 		Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now).
+		// A prepaid plan stops itself: once as many renewals have fired as the
+		// operator allowed, the client is left to expire like any other.
+		Where("reset_max <= 0 or reset_count < reset_max").
 		Where("email IN (?)", tx.Table("client_inbounds ci").
 			Select("c.email").
 			Joins("JOIN clients c ON c.id = ci.client_id").
@@ -411,12 +414,29 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 			if !ok {
 				continue
 			}
+			// One allowance per period, not per tick: a client away for three
+			// cycles must not catch up three of them against a prepaid cap.
 			newExpiryTime := traffic.ExpiryTime
+			renewals := 0
 			for newExpiryTime < now {
+				if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax {
+					break
+				}
 				newExpiryTime += (int64(traffic.Reset) * 86400000)
+				renewals++
+			}
+			if renewals == 0 {
+				continue
 			}
 			c["expiryTime"] = newExpiryTime
 			traffic.ExpiryTime = newExpiryTime
+			traffic.ResetCount += renewals
+			if newExpiryTime <= now {
+				// Cap ran out mid-catch-up and the client is still expired: enabling it
+				// for disableInvalidClients to undo adds and removes an xray user for nothing.
+				clients[client_index] = any(c)
+				continue
+			}
 			traffic.Down = 0
 			traffic.Up = 0
 			if !traffic.Enable {
@@ -508,10 +528,11 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
 		ExpiryTime: client.ExpiryTime,
 		Enable:     client.Enable,
 		Reset:      client.Reset,
+		ResetMax:   client.ResetMax,
 	}
 	return tx.Clauses(clause.OnConflict{
 		Columns:   []clause.Column{{Name: "email"}},
-		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset"}),
+		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_max"}),
 	}).Create(&clientTraffic).Error
 }
 
@@ -524,6 +545,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
 			"total":       client.TotalGB,
 			"expiry_time": client.ExpiryTime,
 			"reset":       client.Reset,
+			"reset_max":   client.ResetMax,
 		})
 	err := result.Error
 	return err

+ 4 - 1
internal/web/translation/ar-EG.json

@@ -869,7 +869,10 @@
         "delOrphans": "تم حذف {count} عميل غير مرتبط",
         "imported": "تم استيراد {count} عميل",
         "importedMixed": "{ok} تم استيرادهم، {failed} تم تخطيهم"
-      }
+      },
+      "renewMax": "الحد الأقصى للتجديدات",
+      "renewMaxDesc": "عدد المرات التي يمكن أن يعمل فيها التجديد التلقائي قبل ترك العميل ينتهي. القيمة 0 تعني بلا حد. تعويض عدة فترات فائتة يستهلك تجديدًا واحدًا لكل فترة.",
+      "renewsUsed": "التجديدات المستخدمة"
     },
     "groups": {
       "name": "الاسم",

+ 4 - 1
internal/web/translation/en-US.json

@@ -869,7 +869,10 @@
         "delOrphans": "{count} unattached clients deleted",
         "imported": "{count} clients imported",
         "importedMixed": "{ok} imported, {failed} skipped"
-      }
+      },
+      "renewMax": "Max renewals",
+      "renewMaxDesc": "How many times auto-renew may fire before the client is left to expire. 0 means no limit. Catching up several missed periods spends one renewal per period.",
+      "renewsUsed": "Renewals used"
     },
     "groups": {
       "name": "Name",

+ 4 - 1
internal/web/translation/es-ES.json

@@ -869,7 +869,10 @@
         "delOrphans": "{count} clientes sin entrante eliminados",
         "imported": "{count} clientes importados",
         "importedMixed": "{ok} importados, {failed} omitidos"
-      }
+      },
+      "renewMax": "Renovaciones máximas",
+      "renewMaxDesc": "Cuántas veces puede activarse la renovación automática antes de dejar que el cliente caduque. 0 significa sin límite. Recuperar varios periodos perdidos consume una renovación por periodo.",
+      "renewsUsed": "Renovaciones usadas"
     },
     "groups": {
       "name": "Nombre",

+ 4 - 1
internal/web/translation/fa-IR.json

@@ -869,7 +869,10 @@
         "delOrphans": "{count} کلاینت بدون اینباند حذف شد",
         "imported": "{count} کلاینت وارد شد",
         "importedMixed": "{ok} وارد شد، {failed} رد شد"
-      }
+      },
+      "renewMax": "حداکثر تعداد تمدید",
+      "renewMaxDesc": "تمدید خودکار حداکثر چند بار اجرا شود پیش از آنکه کلاینت منقضی بماند. مقدار ۰ یعنی بدون محدودیت. جبران چند دورهٔ ازدست‌رفته، برای هر دوره یک تمدید مصرف می‌کند.",
+      "renewsUsed": "تمدیدهای استفاده‌شده"
     },
     "groups": {
       "name": "نام",

+ 4 - 1
internal/web/translation/id-ID.json

@@ -869,7 +869,10 @@
         "delOrphans": "{count} klien tanpa inbound dihapus",
         "imported": "{count} klien diimpor",
         "importedMixed": "{ok} diimpor, {failed} dilewati"
-      }
+      },
+      "renewMax": "Maksimum perpanjangan",
+      "renewMaxDesc": "Berapa kali perpanjangan otomatis boleh berjalan sebelum klien dibiarkan kedaluwarsa. 0 berarti tanpa batas. Mengejar beberapa periode yang terlewat menghabiskan satu perpanjangan per periode.",
+      "renewsUsed": "Perpanjangan terpakai"
     },
     "groups": {
       "name": "Nama",

+ 4 - 1
internal/web/translation/ja-JP.json

@@ -869,7 +869,10 @@
         "delOrphans": "未アタッチの {count} 件のクライアントを削除しました",
         "imported": "{count} 件のクライアントをインポートしました",
         "importedMixed": "{ok} 件インポート、{failed} 件スキップ"
-      }
+      },
+      "renewMax": "最大更新回数",
+      "renewMaxDesc": "自動更新が実行される最大回数です。これを超えるとクライアントはそのまま失効します。0 は無制限。複数の未処理期間をまとめて処理する場合、1 期間につき 1 回消費します。",
+      "renewsUsed": "使用済み更新回数"
     },
     "groups": {
       "name": "名前",

+ 4 - 1
internal/web/translation/pt-BR.json

@@ -869,7 +869,10 @@
         "delOrphans": "{count} clientes sem inbound excluídos",
         "imported": "{count} clientes importados",
         "importedMixed": "{ok} importados, {failed} ignorados"
-      }
+      },
+      "renewMax": "Renovações máximas",
+      "renewMaxDesc": "Quantas vezes a renovação automática pode ocorrer antes de o cliente ser deixado a expirar. 0 significa sem limite. Recuperar vários períodos perdidos consome uma renovação por período.",
+      "renewsUsed": "Renovações usadas"
     },
     "groups": {
       "name": "Nome",

+ 4 - 1
internal/web/translation/ru-RU.json

@@ -869,7 +869,10 @@
         "delOrphans": "Удалено клиентов без входящего: {count}",
         "imported": "Импортировано клиентов: {count}",
         "importedMixed": "Импортировано: {ok}, пропущено: {failed}"
-      }
+      },
+      "renewMax": "Лимит продлений",
+      "renewMaxDesc": "Сколько раз автопродление может сработать, прежде чем клиент будет оставлен истекать. 0 — без ограничения. Догон нескольких пропущенных периодов расходует по одному продлению на период.",
+      "renewsUsed": "Продлений израсходовано"
     },
     "groups": {
       "name": "Имя",

+ 4 - 1
internal/web/translation/tr-TR.json

@@ -869,7 +869,10 @@
         "delOrphans": "{count} bağsız kullanıcı silindi",
         "imported": "{count} kullanıcı içe aktarıldı",
         "importedMixed": "{ok} içe aktarıldı, {failed} atlandı"
-      }
+      },
+      "renewMax": "En fazla yenileme",
+      "renewMaxDesc": "İstemcinin süresi dolmaya bırakılmadan önce otomatik yenilemenin kaç kez çalışabileceği. 0 sınırsız demektir. Kaçırılan birden fazla dönemi telafi etmek, dönem başına bir yenileme harcar.",
+      "renewsUsed": "Kullanılan yenileme"
     },
     "groups": {
       "name": "İsim",

+ 4 - 1
internal/web/translation/uk-UA.json

@@ -869,7 +869,10 @@
         "delOrphans": "Видалено клієнтів без вхідного: {count}",
         "imported": "Імпортовано клієнтів: {count}",
         "importedMixed": "Імпортовано: {ok}, пропущено: {failed}"
-      }
+      },
+      "renewMax": "Ліміт подовжень",
+      "renewMaxDesc": "Скільки разів автоподовження може спрацювати, перш ніж клієнта буде залишено спливати. 0 — без обмеження. Надолуження кількох пропущених періодів витрачає по одному подовженню на період.",
+      "renewsUsed": "Подовжень витрачено"
     },
     "groups": {
       "name": "Назва",

+ 4 - 1
internal/web/translation/vi-VN.json

@@ -869,7 +869,10 @@
         "delOrphans": "Đã xóa {count} khách hàng không gắn inbound",
         "imported": "Đã nhập {count} khách hàng",
         "importedMixed": "Đã nhập {ok}, bỏ qua {failed}"
-      }
+      },
+      "renewMax": "Số lần gia hạn tối đa",
+      "renewMaxDesc": "Gia hạn tự động được phép chạy bao nhiêu lần trước khi để khách hàng hết hạn. 0 nghĩa là không giới hạn. Bù lại nhiều kỳ đã bỏ lỡ sẽ tiêu tốn một lần gia hạn cho mỗi kỳ.",
+      "renewsUsed": "Số lần gia hạn đã dùng"
     },
     "groups": {
       "name": "Tên",

+ 4 - 1
internal/web/translation/zh-CN.json

@@ -869,7 +869,10 @@
         "delOrphans": "已删除 {count} 个未关联的客户端",
         "imported": "已导入 {count} 个客户端",
         "importedMixed": "已导入 {ok} 个,跳过 {failed} 个"
-      }
+      },
+      "renewMax": "最大续期次数",
+      "renewMaxDesc": "自动续期最多可触发的次数,达到后客户端将自然到期。填 0 表示不限制。补齐多个错过的周期时,每个周期消耗一次续期。",
+      "renewsUsed": "已用续期次数"
     },
     "groups": {
       "name": "名称",

+ 4 - 1
internal/web/translation/zh-TW.json

@@ -869,7 +869,10 @@
         "delOrphans": "已刪除 {count} 個未關聯的客戶端",
         "imported": "已匯入 {count} 個客戶端",
         "importedMixed": "已匯入 {ok} 個,跳過 {failed} 個"
-      }
+      },
+      "renewMax": "最大續期次數",
+      "renewMaxDesc": "自動續期最多可觸發的次數,達到後用戶端將自然到期。填 0 表示不限制。補齊多個錯過的週期時,每個週期消耗一次續期。",
+      "renewsUsed": "已用續期次數"
     },
     "groups": {
       "name": "名稱",

+ 17 - 13
internal/xray/client_traffic.go

@@ -3,17 +3,21 @@ package xray
 // ClientTraffic represents traffic statistics and limits for a specific client.
 // It tracks upload/download usage, expiry times, and online status for inbound clients.
 type ClientTraffic struct {
-	Id           int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
-	InboundId    int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
-	Enable       bool   `json:"enable" form:"enable" example:"true"`
-	Email        string `json:"email" form:"email" gorm:"unique" example:"user1"`
-	UUID         string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
-	SubId        string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
-	Up           int64  `json:"up" form:"up" example:"1048576"`
-	Down         int64  `json:"down" form:"down" example:"2097152"`
-	ExpiryTime   int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
-	Total        int64  `json:"total" form:"total" example:"10737418240"`
-	Reset        int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
-	LastOnline   int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
-	LastSubFetch int64  `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"`
+	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
+	InboundId  int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
+	Enable     bool   `json:"enable" form:"enable" example:"true"`
+	Email      string `json:"email" form:"email" gorm:"unique" example:"user1"`
+	UUID       string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
+	SubId      string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
+	Up         int64  `json:"up" form:"up" example:"1048576"`
+	Down       int64  `json:"down" form:"down" example:"2097152"`
+	ExpiryTime int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
+	Total      int64  `json:"total" form:"total" example:"10737418240"`
+	Reset      int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
+	// ResetMax caps how many times auto-renew may fire; 0 means no cap.
+	ResetMax int `json:"resetMax" form:"resetMax" gorm:"default:0" example:"0"`
+	// ResetCount is how many have fired, so a prepaid plan stops on its own.
+	ResetCount   int   `json:"resetCount" form:"resetCount" gorm:"default:0" example:"0"`
+	LastOnline   int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
+	LastSubFetch int64 `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"`
 }