Explorar o código

feat(clients): renew on a calendar day instead of a rolling interval (#6239)

* feat(clients): renew on a calendar day instead of a rolling interval

Auto-renew advances the expiry by a fixed number of milliseconds, so a client
set to 30 days drifts against the calendar: renewing on 31 January lands on
2 March, and by the end of the year the billing day has wandered a fortnight
from where the operator's own plan resets.

Add a per-client renewal day. When set, the expiry steps whole calendar months
at midnight in the panel's time zone. A month too short for the chosen day
renews on its last day and the following month returns to the chosen one, so
the 31st does not decay into the 28th permanently.

Zero keeps the interval mode, so existing clients are untouched.

The interval branch now also refuses a zero step. It is unreachable while the
selection filter holds, but that loop runs on the single traffic writer, and a
zero interval there would hang every panel mutation behind it.

* fix(clients): persist the calendar renewal day on the client record

resetDay 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: an ordinary edit, an attach to a second inbound, a traffic reset on
a disabled client. Calendar mode turned itself off during normal use and
the operator only found out a month later.

Adds reset_day to ClientRecord and threads it through ToRecord, ToClient,
applyClientRecordMerge and the record update map, so the value survives
the round trip. The clients page filter and ClientSlim now recognise the
mode, nodeClientRenewed classifies a calendar renewal as a renewal, the
node snapshot merge carries reset_day, and the service layer rejects a
day outside 0-31 rather than clamping it silently.

Also renames the label keys to renewOnDay to keep them apart from the
existing renewDays, translates them and the new RESET_DAY subscription
placeholder in all 13 locales, adds the field to the bulk-add modal, and
drops the stray internal/web/dist/.gitkeep build stub.

* fix(clients): let the billing day 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 moving a
client from the 20th to the 5th updated the inbound settings JSON while
clients.reset_day kept the old value and the renewal kept using it.

The existing test did not catch it: it asserted the day survived an
unrelated edit, and it survived precisely because nothing on that path
ever wrote it. TestClientEditChangesTheBillingDay moves the day and then
switches calendar mode off again; 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]>
Co-authored-by: Sanaei <[email protected]>
n0ctal hai 19 horas
pai
achega
b8903fadf4
Modificáronse 34 ficheiros con 730 adicións e 10 borrados
  1. 17 0
      frontend/public/openapi.json
  2. 4 0
      frontend/src/generated/examples.ts
  3. 15 0
      frontend/src/generated/schemas.ts
  4. 3 0
      frontend/src/generated/types.ts
  5. 3 0
      frontend/src/generated/zod.ts
  6. 1 0
      frontend/src/lib/remark/remarkVariables.ts
  7. 11 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  8. 14 0
      frontend/src/pages/clients/ClientFormModal.tsx
  9. 3 0
      frontend/src/schemas/client.ts
  10. 10 0
      internal/database/model/model.go
  11. 5 0
      internal/sub/remark_vars.go
  12. 0 0
      internal/web/dist/.gitkeep
  13. 42 0
      internal/web/service/calendar_renew.go
  14. 116 0
      internal/web/service/calendar_renew_test.go
  15. 16 0
      internal/web/service/client_crud.go
  16. 1 0
      internal/web/service/client_link.go
  17. 4 2
      internal/web/service/client_paging.go
  18. 391 0
      internal/web/service/inbound_autorenew_calendar_test.go
  19. 6 5
      internal/web/service/inbound_node.go
  20. 26 3
      internal/web/service/inbound_traffic.go
  21. 3 0
      internal/web/translation/ar-EG.json
  22. 3 0
      internal/web/translation/en-US.json
  23. 3 0
      internal/web/translation/es-ES.json
  24. 3 0
      internal/web/translation/fa-IR.json
  25. 3 0
      internal/web/translation/id-ID.json
  26. 3 0
      internal/web/translation/ja-JP.json
  27. 3 0
      internal/web/translation/pt-BR.json
  28. 3 0
      internal/web/translation/ru-RU.json
  29. 3 0
      internal/web/translation/tr-TR.json
  30. 3 0
      internal/web/translation/uk-UA.json
  31. 3 0
      internal/web/translation/vi-VN.json
  32. 3 0
      internal/web/translation/zh-CN.json
  33. 3 0
      internal/web/translation/zh-TW.json
  34. 3 0
      internal/xray/client_traffic.go

+ 17 - 0
frontend/public/openapi.json

@@ -1118,6 +1118,10 @@
             "description": "Reset period in days",
             "type": "integer"
           },
+          "resetDay": {
+            "description": "Calendar renewal day 1-31, 0 = interval mode",
+            "type": "integer"
+          },
           "resetMax": {
             "description": "Max auto-renew count, 0 = unlimited",
             "type": "integer"
@@ -1166,6 +1170,7 @@
           "expiryTime",
           "limitIp",
           "reset",
+          "resetDay",
           "resetMax",
           "security",
           "subId",
@@ -1259,6 +1264,9 @@
           "reset": {
             "type": "integer"
           },
+          "resetDay": {
+            "type": "integer"
+          },
           "resetMax": {
             "type": "integer"
           },
@@ -1308,6 +1316,7 @@
           "privateKey",
           "publicKey",
           "reset",
+          "resetDay",
           "resetMax",
           "reverse",
           "secret",
@@ -1379,6 +1388,11 @@
             "example": 0,
             "type": "integer"
           },
+          "resetDay": {
+            "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+            "example": 0,
+            "type": "integer"
+          },
           "resetMax": {
             "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
             "example": 0,
@@ -1414,6 +1428,7 @@
           "lastSubFetch",
           "reset",
           "resetCount",
+          "resetDay",
           "resetMax",
           "subId",
           "total",
@@ -3357,6 +3372,7 @@
                           "lastSubFetch": 1735680000000,
                           "reset": 0,
                           "resetCount": 0,
+                          "resetDay": 0,
                           "resetMax": 0,
                           "subId": "i7tvdpeffi0hvvf1",
                           "total": 10737418240,
@@ -8162,6 +8178,7 @@
                     "lastSubFetch": 1735680000000,
                     "reset": 0,
                     "resetCount": 0,
+                    "resetDay": 0,
                     "resetMax": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,

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

@@ -258,6 +258,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "privateKey": "",
     "publicKey": "",
     "reset": 0,
+    "resetDay": 0,
     "resetMax": 0,
     "reverse": null,
     "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
@@ -293,6 +294,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "privateKey": "",
     "publicKey": "",
     "reset": 0,
+    "resetDay": 0,
     "resetMax": 0,
     "reverse": null,
     "secret": "",
@@ -317,6 +319,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "lastSubFetch": 1735680000000,
     "reset": 0,
     "resetCount": 0,
+    "resetDay": 0,
     "resetMax": 0,
     "subId": "i7tvdpeffi0hvvf1",
     "total": 10737418240,
@@ -485,6 +488,7 @@ export const EXAMPLES: Record<string, unknown> = {
         "lastSubFetch": 1735680000000,
         "reset": 0,
         "resetCount": 0,
+        "resetDay": 0,
         "resetMax": 0,
         "subId": "i7tvdpeffi0hvvf1",
         "total": 10737418240,

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

@@ -1092,6 +1092,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Reset period in days",
         "type": "integer"
       },
+      "resetDay": {
+        "description": "Calendar renewal day 1-31, 0 = interval mode",
+        "type": "integer"
+      },
       "resetMax": {
         "description": "Max auto-renew count, 0 = unlimited",
         "type": "integer"
@@ -1140,6 +1144,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expiryTime",
       "limitIp",
       "reset",
+      "resetDay",
       "resetMax",
       "security",
       "subId",
@@ -1233,6 +1238,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "reset": {
         "type": "integer"
       },
+      "resetDay": {
+        "type": "integer"
+      },
       "resetMax": {
         "type": "integer"
       },
@@ -1282,6 +1290,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "privateKey",
       "publicKey",
       "reset",
+      "resetDay",
       "resetMax",
       "reverse",
       "secret",
@@ -1353,6 +1362,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 0,
         "type": "integer"
       },
+      "resetDay": {
+        "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+        "example": 0,
+        "type": "integer"
+      },
       "resetMax": {
         "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
         "example": 0,
@@ -1388,6 +1402,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "lastSubFetch",
       "reset",
       "resetCount",
+      "resetDay",
       "resetMax",
       "subId",
       "total",

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

@@ -268,6 +268,7 @@ export interface Client {
   privateKey?: string;
   publicKey?: string;
   reset: number;
+  resetDay: number;
   resetMax: number;
   reverse?: ClientReverse | null;
   secret?: string;
@@ -305,6 +306,7 @@ export interface ClientRecord {
   privateKey: string;
   publicKey: string;
   reset: number;
+  resetDay: number;
   resetMax: number;
   reverse: unknown;
   secret: string;
@@ -331,6 +333,7 @@ export interface ClientTraffic {
   lastSubFetch: number;
   reset: number;
   resetCount: number;
+  resetDay: number;
   resetMax: number;
   subId: string;
   total: number;

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

@@ -288,6 +288,7 @@ export const ClientSchema = z.object({
   privateKey: z.string().optional(),
   publicKey: z.string().optional(),
   reset: z.number().int(),
+  resetDay: z.number().int(),
   resetMax: z.number().int(),
   reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
   secret: z.string().optional(),
@@ -327,6 +328,7 @@ export const ClientRecordSchema = z.object({
   privateKey: z.string(),
   publicKey: z.string(),
   reset: z.number().int(),
+  resetDay: z.number().int(),
   resetMax: z.number().int(),
   reverse: z.unknown(),
   secret: z.string(),
@@ -355,6 +357,7 @@ export const ClientTrafficSchema = z.object({
   lastSubFetch: z.number().int(),
   reset: z.number().int(),
   resetCount: z.number().int(),
+  resetDay: z.number().int(),
   resetMax: z.number().int(),
   subId: z.string(),
   total: z.number().int(),

+ 1 - 0
frontend/src/lib/remark/remarkVariables.ts

@@ -45,6 +45,7 @@ export const REMARK_VARIABLES: RemarkVar[] = [
   { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
   { token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
   { token: 'RESET_DAYS', group: 'time', sample: '30' },
+  { token: 'RESET_DAY', group: 'time', sample: '15' },
   // Connection (inbound config descriptors)
   { token: 'PROTOCOL', group: 'connection', sample: 'VLESS' },
   { token: 'TRANSPORT', group: 'connection', sample: 'ws' },

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

@@ -37,6 +37,7 @@ const EMPTY: ClientBulkAddFormValues = {
   totalGB: 0,
   expiryTime: 0,
   reset: 0,
+  resetDay: 0,
   resetMax: 0,
   inboundIds: [],
 };
@@ -177,6 +178,7 @@ export default function ClientBulkAddModal({
           totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
           expiryTime: current.expiryTime,
           reset: Number(current.reset) || 0,
+          resetDay: Number(current.resetDay) || 0,
           resetMax: Number(current.resetMax) || 0,
           limitIp: Number(current.limitIp) || 0,
           limitHwid: Number(current.limitHwid) || 0,
@@ -377,6 +379,15 @@ export default function ClientBulkAddModal({
               <InputNumber min={0} />
             </FormField>
 
+            <FormField
+              name="resetDay"
+              label={t('pages.clients.renewOnDay')}
+              tooltip={t('pages.clients.renewOnDayDesc')}
+              transform={{ output: (v) => Number(v) || 0 }}
+            >
+              <InputNumber min={0} max={31} />
+            </FormField>
+
             <FormField
               name="resetMax"
               label={t('pages.clients.renewMax')}

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

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

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

@@ -33,6 +33,7 @@ export const ClientRecordSchema = z.object({
   comment: z.string().optional(),
   enable: z.boolean().optional(),
   reset: z.number().optional(),
+  resetDay: z.number().optional(),
   resetMax: z.number().optional(),
   inboundIds: nullableNumberArray.optional(),
   traffic: ClientTrafficSchema.nullable().optional(),
@@ -208,6 +209,7 @@ export const ClientFormSchema = z.object({
   delayedStart: z.boolean(),
   delayedDays: z.number().int().min(0),
   reset: z.number().int().min(0),
+  resetDay: z.number().int().min(0).max(31),
   resetMax: z.number().int().min(0),
   limitIp: z.number().int().min(0),
   limitHwid: z.number().int().min(0),
@@ -248,6 +250,7 @@ export const ClientBulkAddFormSchema = z.object({
   totalGB: z.number().min(0),
   expiryTime: z.number(),
   reset: z.number().int().min(0),
+  resetDay: z.number().int().min(0).max(31),
   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
+	ResetDay     int            `json:"resetDay" form:"resetDay"`     // Calendar renewal day 1-31, 0 = interval mode
 	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
@@ -925,6 +926,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"`
+	ResetDay     int    `json:"resetDay" gorm:"column:reset_day;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"`
@@ -1107,6 +1109,7 @@ func (c *Client) ToRecord() *ClientRecord {
 		Group:      c.Group,
 		Comment:    c.Comment,
 		Reset:      c.Reset,
+		ResetDay:   c.ResetDay,
 		ResetMax:   c.ResetMax,
 		CreatedAt:  c.CreatedAt,
 		UpdatedAt:  c.UpdatedAt,
@@ -1161,6 +1164,7 @@ func (r *ClientRecord) ToClient() *Client {
 		Group:      r.Group,
 		Comment:    r.Comment,
 		Reset:      r.Reset,
+		ResetDay:   r.ResetDay,
 		ResetMax:   r.ResetMax,
 		CreatedAt:  r.CreatedAt,
 		UpdatedAt:  r.UpdatedAt,
@@ -1310,6 +1314,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.Reset = incoming.Reset
 		}
 	}
+	if existing.ResetDay != incoming.ResetDay && incoming.ResetDay != 0 {
+		if incomingNewer || existing.ResetDay == 0 {
+			keep("resetDay", existing.ResetDay, incoming.ResetDay, incoming.ResetDay)
+			existing.ResetDay = incoming.ResetDay
+		}
+	}
 	if existing.ResetMax != incoming.ResetMax && incoming.ResetMax != 0 {
 		if incomingNewer || existing.ResetMax == 0 {
 			keep("resetMax", existing.ResetMax, incoming.ResetMax, incoming.ResetMax)

+ 5 - 0
internal/sub/remark_vars.go

@@ -263,6 +263,11 @@ func remarkVarValue(token string, ctx remarkContext) string {
 			return strconv.Itoa(c.Reset)
 		}
 		return ""
+	case "RESET_DAY":
+		if c.ResetDay > 0 {
+			return strconv.Itoa(c.ResetDay)
+		}
+		return ""
 	case "STATUS_EMOJI":
 		return statusEmoji(st)
 	case "USAGE_PERCENTAGE":

+ 0 - 0
internal/web/dist/.gitkeep


+ 42 - 0
internal/web/service/calendar_renew.go

@@ -0,0 +1,42 @@
+package service
+
+import "time"
+
+// nextCalendarRenewal returns the next renewal strictly after from, at midnight
+// in loc; a missing day clamps to the month's last, so the 31st comes back (#6106).
+func nextCalendarRenewal(from time.Time, day int, loc *time.Location) time.Time {
+	if loc == nil {
+		loc = time.UTC
+	}
+	if day < 1 {
+		day = 1
+	}
+	if day > 31 {
+		day = 31
+	}
+	local := from.In(loc)
+
+	candidate := calendarDay(local.Year(), local.Month(), day, loc)
+	if !candidate.After(local) {
+		year, month := local.Year(), local.Month()+1
+		if month > time.December {
+			year, month = year+1, time.January
+		}
+		candidate = calendarDay(year, month, day, loc)
+	}
+	return candidate
+}
+
+// Clamped rather than normalized: time.Date rolls 31 February into March, which
+// is the drift this mode exists to avoid.
+func calendarDay(year int, month time.Month, day int, loc *time.Location) time.Time {
+	last := daysInMonth(year, month)
+	if day > last {
+		day = last
+	}
+	return time.Date(year, month, day, 0, 0, 0, 0, loc)
+}
+
+func daysInMonth(year int, month time.Month) int {
+	return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
+}

+ 116 - 0
internal/web/service/calendar_renew_test.go

@@ -0,0 +1,116 @@
+package service
+
+import (
+	"testing"
+	"time"
+)
+
+func TestNextCalendarRenewal_ClampsToShortMonths(t *testing.T) {
+	utc := time.UTC
+	cases := []struct {
+		name string
+		from time.Time
+		day  int
+		want time.Time
+	}{
+		{
+			name: "31st in a 28-day February",
+			from: time.Date(2026, time.January, 31, 0, 0, 0, 0, utc),
+			day:  31,
+			want: time.Date(2026, time.February, 28, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "31st returns to the 31st after the short month",
+			from: time.Date(2026, time.February, 28, 0, 0, 0, 0, utc),
+			day:  31,
+			want: time.Date(2026, time.March, 31, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "29th in a leap February",
+			from: time.Date(2028, time.January, 29, 0, 0, 0, 0, utc),
+			day:  29,
+			want: time.Date(2028, time.February, 29, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "31st in a 30-day month",
+			from: time.Date(2026, time.March, 31, 0, 0, 0, 0, utc),
+			day:  31,
+			want: time.Date(2026, time.April, 30, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "December rolls into January",
+			from: time.Date(2026, time.December, 5, 0, 0, 0, 0, utc),
+			day:  5,
+			want: time.Date(2027, time.January, 5, 0, 0, 0, 0, utc),
+		},
+		{
+			name: "later in the same month renews this month",
+			from: time.Date(2026, time.June, 3, 12, 0, 0, 0, utc),
+			day:  20,
+			want: time.Date(2026, time.June, 20, 0, 0, 0, 0, utc),
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := nextCalendarRenewal(tc.from, tc.day, utc)
+			if !got.Equal(tc.want) {
+				t.Fatalf("next renewal = %s, want %s", got.Format(time.RFC3339), tc.want.Format(time.RFC3339))
+			}
+		})
+	}
+}
+
+// The renewal instant is midnight in the panel's zone, not in UTC: an operator
+// billing on the 1st expects the period to turn over at their local midnight.
+func TestNextCalendarRenewal_UsesPanelZone(t *testing.T) {
+	loc, err := time.LoadLocation("Asia/Tehran")
+	if err != nil {
+		t.Skip("zone database unavailable")
+	}
+	from := time.Date(2026, time.June, 15, 12, 0, 0, 0, time.UTC)
+	got := nextCalendarRenewal(from, 1, loc)
+
+	if got.Location() != loc {
+		t.Fatalf("renewal computed in %s, want the panel zone", got.Location())
+	}
+	y, m, d := got.Date()
+	if y != 2026 || m != time.July || d != 1 {
+		t.Fatalf("renewal date = %04d-%02d-%02d, want 2026-07-01", y, m, d)
+	}
+	if h, mi, s := got.Clock(); h != 0 || mi != 0 || s != 0 {
+		t.Fatalf("renewal at %02d:%02d:%02d, want local midnight", h, mi, s)
+	}
+}
+
+// Crossing a DST boundary must still land on local midnight rather than
+// drifting an hour, which a fixed 24h*N step cannot promise.
+func TestNextCalendarRenewal_SurvivesDaylightSaving(t *testing.T) {
+	loc, err := time.LoadLocation("Europe/Berlin")
+	if err != nil {
+		t.Skip("zone database unavailable")
+	}
+	// Berlin moves to summer time on 29 March 2026.
+	from := time.Date(2026, time.March, 10, 0, 0, 0, 0, loc)
+	got := nextCalendarRenewal(from, 10, loc)
+
+	if h, mi, _ := got.Clock(); h != 0 || mi != 0 {
+		t.Fatalf("renewal at %02d:%02d local, want midnight across the DST change", h, mi)
+	}
+	if got.Day() != 10 || got.Month() != time.April {
+		t.Fatalf("renewal = %s, want 10 April", got.Format(time.RFC3339))
+	}
+}
+
+func TestNextCalendarRenewal_AlwaysMovesForward(t *testing.T) {
+	utc := time.UTC
+	from := time.Date(2026, time.May, 20, 0, 0, 0, 0, utc)
+	// Same day: the boundary has already passed today, so the next one is a
+	// month away rather than the instant we started from.
+	got := nextCalendarRenewal(from, 20, utc)
+	if !got.After(from) {
+		t.Fatalf("next renewal %s is not after %s", got, from)
+	}
+	if got.Month() != time.June {
+		t.Fatalf("next renewal = %s, want June", got.Format(time.RFC3339))
+	}
+}

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

@@ -43,6 +43,15 @@ func validateClientSubID(subID string) error {
 	return nil
 }
 
+// Rejected rather than clamped: nextCalendarRenewal would silently move an
+// out-of-range day, and a negative one drops out of the renewal query entirely.
+func validateClientResetDay(day int) error {
+	if day < 0 || day > 31 {
+		return common.NewError("client resetDay must be between 0 and 31, got:", day)
+	}
+	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 {
@@ -66,6 +75,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 	if err := validateClientSubID(client.SubID); err != nil {
 		return false, err
 	}
+	if err := validateClientResetDay(client.ResetDay); err != nil {
+		return false, err
+	}
 	if err := validateClientResetMax(client.ResetMax); err != nil {
 		return false, err
 	}
@@ -356,6 +368,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	if err := validateClientSubID(updated.SubID); err != nil {
 		return false, err
 	}
+	if err := validateClientResetDay(updated.ResetDay); err != nil {
+		return false, err
+	}
 	if err := validateClientResetMax(updated.ResetMax); err != nil {
 		return false, err
 	}
@@ -481,6 +496,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 				"tg_id":             merged.TgID,
 				"comment":           merged.Comment,
 				"reset":             merged.Reset,
+				"reset_day":         merged.ResetDay,
 				"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.ResetDay = incoming.ResetDay
 	row.ResetMax = incoming.ResetMax
 	if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
 		row.CreatedAt = incoming.CreatedAt

+ 4 - 2
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"`
+	ResetDay   int                 `json:"resetDay"`
 	ResetMax   int                 `json:"resetMax"`
 	Group      string              `json:"group,omitempty"`
 	Comment    string              `json:"comment,omitempty"`
@@ -246,9 +247,9 @@ func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines [
 	}
 	switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
 	case "on":
-		where("COALESCE(c.reset, 0) > 0")
+		where("(COALESCE(c.reset, 0) > 0 OR COALESCE(c.reset_day, 0) > 0)")
 	case "off":
-		where("COALESCE(c.reset, 0) <= 0")
+		where("(COALESCE(c.reset, 0) <= 0 AND COALESCE(c.reset_day, 0) <= 0)")
 	}
 	switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
 	case "yes":
@@ -606,6 +607,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim {
 		LimitIP:    c.LimitIP,
 		LimitHwid:  c.LimitHwid,
 		Reset:      c.Reset,
+		ResetDay:   c.ResetDay,
 		ResetMax:   c.ResetMax,
 		Group:      c.Group,
 		Comment:    c.Comment,

+ 391 - 0
internal/web/service/inbound_autorenew_calendar_test.go

@@ -0,0 +1,391 @@
+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"
+)
+
+// pinPanelZone fixes the panel time zone so the assertions below can talk about
+// calendar days without the test machine's own zone shifting them.
+func pinPanelZone(t *testing.T, name string) *time.Location {
+	t.Helper()
+	loc, err := time.LoadLocation(name)
+	if err != nil {
+		t.Skipf("zone database unavailable: %v", err)
+	}
+	if err := database.GetDB().Create(&model.Setting{Key: "timeLocation", Value: name}).Error; err != nil {
+		t.Fatalf("pin panel zone: %v", err)
+	}
+	return loc
+}
+
+// Calendar mode renews on the same day each month. The interval mode drifts —
+// 30 days from 31 January is 2 March — which is the whole reason for the mode.
+func TestAutoRenewClients_CalendarModeLandsOnTheBillingDay(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+	zone := pinPanelZone(t, "UTC")
+
+	// Expired two calendar months ago, billed on the 15th.
+	past := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC)
+	clients := []model.Client{
+		{Email: "cal@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, ResetDay: 15, ExpiryTime: past.UnixMilli()},
+	}
+	ib := mkInbound(t, 30201, 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: "cal@x", Enable: false, Up: 5, Down: 6,
+		ResetDay: 15, ExpiryTime: past.UnixMilli(),
+	}).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", count)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "cal@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	got := time.UnixMilli(row.ExpiryTime).In(zone)
+	if got.Day() != 15 {
+		t.Fatalf("renewed to %s, want the 15th: calendar mode must not drift", got.Format(time.RFC3339))
+	}
+	if !got.After(time.Now()) {
+		t.Fatalf("renewed to %s, which is not in the future", got.Format(time.RFC3339))
+	}
+	if h, m, s := got.Clock(); h != 0 || m != 0 || s != 0 {
+		t.Fatalf("renewed to %02d:%02d:%02d, want midnight", h, m, s)
+	}
+	if row.Up != 0 || row.Down != 0 {
+		t.Fatalf("counters not reset: up=%d down=%d", row.Up, row.Down)
+	}
+	if !row.Enable {
+		t.Fatal("a renewed client must be re-enabled")
+	}
+}
+
+// A client billed on the 31st keeps that day, borrowing the last day only in
+// months that are too short for it.
+func TestAutoRenewClients_CalendarModeClampsShortMonths(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+	zone := pinPanelZone(t, "UTC")
+
+	past := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC)
+	clients := []model.Client{
+		{Email: "eom@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli()},
+	}
+	ib := mkInbound(t, 30202, 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: "eom@x", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli(),
+	}).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 = ?", "eom@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	got := time.UnixMilli(row.ExpiryTime).In(zone)
+	want := firstBillingMidnightAfter(t, time.Now().In(zone), 31, zone)
+	if !got.Equal(want) {
+		t.Fatalf("renewed to %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
+	}
+}
+
+// Deliberately not built on nextCalendarRenewal: it walks a day at a time and
+// derives month length from time.Date's own zero-day trick, so it can disagree.
+func firstBillingMidnightAfter(t *testing.T, from time.Time, day int, loc *time.Location) time.Time {
+	t.Helper()
+	cur := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
+	for i := 0; i < 400; i++ {
+		cur = cur.AddDate(0, 0, 1)
+		want := day
+		if last := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, loc).Day(); want > last {
+			want = last
+		}
+		if cur.Day() == want {
+			return cur
+		}
+	}
+	t.Fatalf("no billing midnight for day %d within a year of %s", day, from)
+	return time.Time{}
+}
+
+// Interval clients must be untouched by the new field.
+func TestAutoRenewClients_IntervalModeUnchanged(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "days@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30203, 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: "days@x", Enable: false, Reset: 30, 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", count)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "days@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if want := past + 30*86400000; row.ExpiryTime != want {
+		t.Fatalf("interval renewal moved to %d, want the old fixed step %d", row.ExpiryTime, want)
+	}
+}
+
+// The selection filter is what keeps a row with neither mode configured out of
+// the renewal loop. That matters more than it looks: the interval step is
+// reset*24h, so a zero interval reaching that loop would spin forever on the
+// single traffic writer. The guard in the loop is a second line of defence and
+// is deliberately unreachable while this filter holds.
+func TestAutoRenewClients_RowWithNoModeIsNotSelected(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "none@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30204, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	// Seeded straight into the table with both modes off, the shape the
+	// selection filter is supposed to exclude.
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "none@x", Enable: false, Reset: 0, ResetDay: 0, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	// Asserted against the query rather than by running the loop: the failure
+	// this guards is a hang, and a stuck goroutine outlives the test's DB.
+	var selected int64
+	if err := db.Model(&xray.ClientTraffic{}).
+		Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", time.Now().UnixMilli()).
+		Where("email = ?", "none@x").
+		Count(&selected).Error; err != nil {
+		t.Fatal(err)
+	}
+	if selected != 0 {
+		t.Fatal("a row with no renewal mode was selected for renewal: it would reach the interval loop and spin forever")
+	}
+
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 0 {
+		t.Fatalf("renewed count = %d, want 0", count)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "none@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.ExpiryTime != past {
+		t.Fatalf("a client with no renewal mode was renewed to %d", row.ExpiryTime)
+	}
+}
+
+// The billing day has to survive the clients table, not just the settings JSON:
+// an ordinary edit rebuilds the client from the record and writes it back (#6106).
+func TestClientEditKeepsTheBillingDay(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{Email: "keep@x", ID: "55555555-5555-5555-5555-555555555555", Enable: true, ResetDay: 20, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()},
+	}
+	ib := mkInbound(t, 30205, 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, "keep@x", 10, 20, 0, 0, true)
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "keep@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.ResetDay != 20 {
+		t.Fatalf("clients.reset_day = %d, want the 20 the client was created with", rec.ResetDay)
+	}
+
+	// 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)
+	}
+
+	// The inbound JSON is what xray and the edit dialog read back, and it is
+	// rebuilt from the record, so it is where a dropped converter field shows.
+	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].ResetDay != 20 {
+		t.Fatalf("inbound settings resetDay = %d after an unrelated edit, want 20: calendar mode was silently turned off", settings.Clients[0].ResetDay)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "keep@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.ResetDay != 20 {
+		t.Fatalf("clients.reset_day = %d after an unrelated edit, want 20", rec.ResetDay)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "keep@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.ResetDay != 20 {
+		t.Fatalf("client_traffics.reset_day = %d after an unrelated edit, want 20", row.ResetDay)
+	}
+}
+
+// The billing day 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; this one fails without it.
+func TestClientEditChangesTheBillingDay(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+
+	clients := []model.Client{
+		{
+			Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, ResetDay: 20,
+			ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
+		},
+	}
+	ib := mkInbound(t, 30206, 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)
+	}
+	edited := rec.ToClient()
+	edited.ResetDay = 5
+	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.ResetDay != 5 {
+		t.Fatalf("clients.reset_day = %d after the operator moved the billing day to the 5th", rec.ResetDay)
+	}
+
+	// Turning calendar mode off has to work too.
+	edited = rec.ToClient()
+	edited.ResetDay = 0
+	edited.Reset = 30
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update back to interval mode: %v", err)
+	}
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after switching mode: %v", err)
+	}
+	if rec.ResetDay != 0 {
+		t.Fatalf("clients.reset_day = %d after the operator switched back to interval mode", rec.ResetDay)
+	}
+}
+
+// The two renewal features meet here: a calendar client is capped like an
+// interval one, spending one allowance per month rather than per tick.
+func TestAutoRenewClients_CalendarModeSpendsOneAllowancePerMonth(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+	zone := pinPanelZone(t, "UTC")
+
+	// Three calendar months behind with one allowance left: a single month step
+	// cannot reach the present, so the client stays expired on its billing day.
+	past := time.Now().In(zone).AddDate(0, -3, 0)
+	past = time.Date(past.Year(), past.Month(), 10, 0, 0, 0, 0, zone)
+	clients := []model.Client{
+		{Email: "calcap@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 10, ResetMax: 3, ExpiryTime: past.UnixMilli()},
+	}
+	ib := mkInbound(t, 30205, 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: "calcap@x", Enable: false, ResetDay: 10, ResetMax: 3, ResetCount: 2,
+		Up: 111, Down: 222, ExpiryTime: past.UnixMilli(),
+	}).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 = ?", "calcap@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	got := time.UnixMilli(row.ExpiryTime).In(zone)
+	if want := past.AddDate(0, 1, 0); !got.Equal(want) {
+		t.Fatalf("renewed to %s, want exactly one month on to %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
+	}
+	if row.ResetCount != 3 {
+		t.Fatalf("resetCount = %d, want 3: one allowance per month stepped", row.ResetCount)
+	}
+	if row.Enable {
+		t.Fatal("a client still expired after a truncated catch-up was enabled")
+	}
+	if row.Up != 111 || row.Down != 222 {
+		t.Fatalf("counters zeroed for a month the client can never use: up=%d down=%d", row.Up, row.Down)
+	}
+}

+ 6 - 5
internal/web/service/inbound_node.go

@@ -237,7 +237,7 @@ func mergeActivationExpiry(existing, node int64) int64 {
 // nodeClientRenewed reports a node-side auto-renew: an absolute deadline moved
 // forward while the node's cumulative counter fell below the stored baseline.
 func nodeClientRenewed(existing *xray.ClientTraffic, cs xray.ClientTraffic, canon, base nodeTrafficCounter) bool {
-	if cs.Reset <= 0 || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 {
+	if (cs.Reset <= 0 && cs.ResetDay <= 0) || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 {
 		return false
 	}
 	if cs.ExpiryTime <= existing.ExpiryTime {
@@ -844,6 +844,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 					Total:      cs.Total,
 					ExpiryTime: cs.ExpiryTime,
 					Reset:      cs.Reset,
+					ResetDay:   cs.ResetDay,
 					Up:         seedUp,
 					Down:       seedDown,
 					LastOnline: cs.LastOnline,
@@ -879,12 +880,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 					fmt.Sprintf(
 						`UPDATE client_traffics
 						 SET up = ?, down = ?, enable = ?, total = ?,
-						     expiry_time = ?, reset = ?, last_online = %s
+						     expiry_time = ?, reset = ?, reset_day = ?, last_online = %s
 						 WHERE email = ?`,
 						database.GreatestExpr("last_online", "?"),
 					),
 					canon.Up, canon.Down, cs.Enable, cs.Total,
-					cs.ExpiryTime, cs.Reset,
+					cs.ExpiryTime, cs.Reset, cs.ResetDay,
 					cs.LastOnline, cs.Email,
 				).Error; err != nil {
 					return false, err
@@ -906,7 +907,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 						`UPDATE client_traffics
 						 SET up = %s, down = %s, enable = %s, total = ?,
 						     expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END,
-						     reset = ?, last_online = %s
+						     reset = ?, reset_day = ?, last_online = %s
 						 WHERE email = ?`,
 						database.ClampedAddExpr("up"),
 						database.ClampedAddExpr("down"),
@@ -914,7 +915,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 						database.GreatestExpr("last_online", "?"),
 					),
 					deltaUp, deltaDown, cs.Enable, cs.Total,
-					cs.ExpiryTime, cs.ExpiryTime, cs.Reset,
+					cs.ExpiryTime, cs.ExpiryTime, cs.Reset, cs.ResetDay,
 					cs.LastOnline, cs.Email,
 				).Error; err != nil {
 					return false, err

+ 26 - 3
internal/web/service/inbound_traffic.go

@@ -333,7 +333,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 	// attached to, so it could be a node inbound even when the client also has
 	// 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).
+		Where("(reset > 0 or reset_day > 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").
@@ -351,6 +351,14 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 		return false, 0, nil
 	}
 
+	renewLocation, locErr := (&SettingService{}).GetTimeLocation()
+	if locErr != nil || renewLocation == nil {
+		// Falling back to UTC keeps renewals happening; the alternative is
+		// skipping them entirely because a setting could not be read.
+		logger.Warning("autoRenewClients: could not read the panel time zone, using UTC:", locErr)
+		renewLocation = time.UTC
+	}
+
 	var inbound_ids []int
 	var inbounds []*model.Inbound
 	needRestart := false
@@ -417,12 +425,25 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 			// 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
+			if traffic.ResetDay <= 0 && traffic.Reset <= 0 {
+				// Unreachable while the selection filter holds: a zero step below
+				// would spin forever on the single traffic writer and hang the panel.
+				continue
+			}
+			at := time.UnixMilli(newExpiryTime)
 			renewals := 0
 			for newExpiryTime < now {
 				if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax {
 					break
 				}
-				newExpiryTime += (int64(traffic.Reset) * 86400000)
+				if traffic.ResetDay > 0 {
+					// Calendar mode: step whole months in the panel's zone, so the
+					// renewal date does not drift the way a fixed 30-day step does.
+					at = nextCalendarRenewal(at, traffic.ResetDay, renewLocation)
+					newExpiryTime = at.UnixMilli()
+				} else {
+					newExpiryTime += (int64(traffic.Reset) * 86400000)
+				}
 				renewals++
 			}
 			if renewals == 0 {
@@ -528,11 +549,12 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
 		ExpiryTime: client.ExpiryTime,
 		Enable:     client.Enable,
 		Reset:      client.Reset,
+		ResetDay:   client.ResetDay,
 		ResetMax:   client.ResetMax,
 	}
 	return tx.Clauses(clause.OnConflict{
 		Columns:   []clause.Column{{Name: "email"}},
-		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_max"}),
+		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_day", "reset_max"}),
 	}).Create(&clientTraffic).Error
 }
 
@@ -545,6 +567,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
 			"total":       client.TotalGB,
 			"expiry_time": client.ExpiryTime,
 			"reset":       client.Reset,
+			"reset_day":   client.ResetDay,
 			"reset_max":   client.ResetMax,
 		})
 	err := result.Error

+ 3 - 0
internal/web/translation/ar-EG.json

@@ -874,6 +874,8 @@
       },
       "renewMax": "الحد الأقصى للتجديدات",
       "renewMaxDesc": "عدد المرات التي يمكن أن يعمل فيها التجديد التلقائي قبل ترك العميل ينتهي. القيمة 0 تعني بلا حد. تعويض عدة فترات فائتة يستهلك تجديدًا واحدًا لكل فترة.",
+      "renewOnDay": "يوم التجديد",
+      "renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.",
       "renewsUsed": "التجديدات المستخدمة"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "الانتهاء كطابع زمني Unix (بالثواني)",
         "descCREATED_UNIX": "وقت الإنشاء كطابع زمني Unix (بالثواني)",
         "descRESET_DAYS": "فترة إعادة تعيين حركة المرور بالأيام",
+        "descRESET_DAY": "يوم الشهر الذي يتم فيه التجديد",
         "descPROTOCOL": "بروتوكول الوارد (VLESS، VMess، Trojan، …)",
         "descTRANSPORT": "شبكة النقل (tcp، ws، grpc، …)",
         "descSECURITY": "أمان النقل (TLS، REALITY، NONE)"

+ 3 - 0
internal/web/translation/en-US.json

@@ -874,6 +874,8 @@
       },
       "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.",
+      "renewOnDay": "Renew on day",
+      "renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.",
       "renewsUsed": "Renewals used"
     },
     "groups": {
@@ -1020,6 +1022,7 @@
         "descEXPIRE_UNIX": "Expiry as a Unix timestamp (seconds)",
         "descCREATED_UNIX": "Creation time as a Unix timestamp (seconds)",
         "descRESET_DAYS": "Traffic reset period in days",
+        "descRESET_DAY": "Calendar renewal day of the month",
         "descPROTOCOL": "Inbound protocol (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Transport network (tcp, ws, grpc, …)",
         "descSECURITY": "Transport security (TLS, REALITY, NONE)"

+ 3 - 0
internal/web/translation/es-ES.json

@@ -874,6 +874,8 @@
       },
       "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.",
+      "renewOnDay": "Renovar el día",
+      "renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.",
       "renewsUsed": "Renovaciones usadas"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "Expiración como marca de tiempo Unix (segundos)",
         "descCREATED_UNIX": "Hora de creación como marca de tiempo Unix (segundos)",
         "descRESET_DAYS": "Periodo de reinicio de tráfico en días",
+        "descRESET_DAY": "Día del mes en que se renueva",
         "descPROTOCOL": "Protocolo del inbound (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Red de transporte (tcp, ws, grpc, …)",
         "descSECURITY": "Seguridad del transporte (TLS, REALITY, NONE)"

+ 3 - 0
internal/web/translation/fa-IR.json

@@ -874,6 +874,8 @@
       },
       "renewMax": "حداکثر تعداد تمدید",
       "renewMaxDesc": "تمدید خودکار حداکثر چند بار اجرا شود پیش از آنکه کلاینت منقضی بماند. مقدار ۰ یعنی بدون محدودیت. جبران چند دورهٔ ازدست‌رفته، برای هر دوره یک تمدید مصرف می‌کند.",
+      "renewOnDay": "روز تمدید",
+      "renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.",
       "renewsUsed": "تمدیدهای استفاده‌شده"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "انقضا به‌صورت مهر زمانی Unix (ثانیه)",
         "descCREATED_UNIX": "زمان ایجاد به‌صورت مهر زمانی Unix (ثانیه)",
         "descRESET_DAYS": "دورهٔ بازنشانی ترافیک به روز",
+        "descRESET_DAY": "روز ماه برای تمدید تقویمی",
         "descPROTOCOL": "پروتکل اینباند (VLESS، VMess، Trojan، …)",
         "descTRANSPORT": "شبکهٔ انتقال (tcp، ws، grpc، …)",
         "descSECURITY": "امنیت انتقال (TLS، REALITY، NONE)"

+ 3 - 0
internal/web/translation/id-ID.json

@@ -874,6 +874,8 @@
       },
       "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.",
+      "renewOnDay": "Perpanjang pada tanggal",
+      "renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.",
       "renewsUsed": "Perpanjangan terpakai"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "Kedaluwarsa sebagai timestamp Unix (detik)",
         "descCREATED_UNIX": "Waktu pembuatan sebagai timestamp Unix (detik)",
         "descRESET_DAYS": "Periode reset trafik dalam hari",
+        "descRESET_DAY": "Tanggal perpanjangan setiap bulan",
         "descPROTOCOL": "Protokol inbound (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Jaringan transport (tcp, ws, grpc, …)",
         "descSECURITY": "Keamanan transport (TLS, REALITY, NONE)"

+ 3 - 0
internal/web/translation/ja-JP.json

@@ -874,6 +874,8 @@
       },
       "renewMax": "最大更新回数",
       "renewMaxDesc": "自動更新が実行される最大回数です。これを超えるとクライアントはそのまま失効します。0 は無制限。複数の未処理期間をまとめて処理する場合、1 期間につき 1 回消費します。",
+      "renewOnDay": "更新する日",
+      "renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。",
       "renewsUsed": "使用済み更新回数"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "有効期限の Unix タイムスタンプ(秒)",
         "descCREATED_UNIX": "作成時刻の Unix タイムスタンプ(秒)",
         "descRESET_DAYS": "トラフィックリセット周期(日数)",
+        "descRESET_DAY": "毎月の更新日",
         "descPROTOCOL": "インバウンドのプロトコル(VLESS、VMess、Trojan など)",
         "descTRANSPORT": "トランスポートネットワーク(tcp、ws、grpc など)",
         "descSECURITY": "トランスポートのセキュリティ(TLS、REALITY、NONE)"

+ 3 - 0
internal/web/translation/pt-BR.json

@@ -874,6 +874,8 @@
       },
       "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.",
+      "renewOnDay": "Renovar no dia",
+      "renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.",
       "renewsUsed": "Renovações usadas"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "Expiração como timestamp Unix (segundos)",
         "descCREATED_UNIX": "Data de criação como timestamp Unix (segundos)",
         "descRESET_DAYS": "Período de redefinição de tráfego em dias",
+        "descRESET_DAY": "Dia do mês em que é renovado",
         "descPROTOCOL": "Protocolo da entrada (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Rede de transporte (tcp, ws, grpc, …)",
         "descSECURITY": "Segurança do transporte (TLS, REALITY, NONE)"

+ 3 - 0
internal/web/translation/ru-RU.json

@@ -874,6 +874,8 @@
       },
       "renewMax": "Лимит продлений",
       "renewMaxDesc": "Сколько раз автопродление может сработать, прежде чем клиент будет оставлен истекать. 0 — без ограничения. Догон нескольких пропущенных периодов расходует по одному продлению на период.",
+      "renewOnDay": "Продлевать числа",
+      "renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.",
       "renewsUsed": "Продлений израсходовано"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "Окончание в виде Unix-метки времени (секунды)",
         "descCREATED_UNIX": "Время создания в виде Unix-метки времени (секунды)",
         "descRESET_DAYS": "Период сброса трафика в днях",
+        "descRESET_DAY": "Число месяца, в которое продлевается доступ",
         "descPROTOCOL": "Протокол входящего (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Транспортная сеть (tcp, ws, grpc, …)",
         "descSECURITY": "Безопасность транспорта (TLS, REALITY, NONE)"

+ 3 - 0
internal/web/translation/tr-TR.json

@@ -874,6 +874,8 @@
       },
       "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.",
+      "renewOnDay": "Yenileme günü",
+      "renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.",
       "renewsUsed": "Kullanılan yenileme"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "Son kullanma Unix zaman damgası olarak (saniye)",
         "descCREATED_UNIX": "Oluşturulma zamanı Unix zaman damgası olarak (saniye)",
         "descRESET_DAYS": "Trafik sıfırlama periyodu (gün)",
+        "descRESET_DAY": "Takvime göre yenileme günü",
         "descPROTOCOL": "Gelen bağlantı protokolü (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Taşıma ağı (tcp, ws, grpc, …)",
         "descSECURITY": "Taşıma güvenliği (TLS, REALITY, NONE)"

+ 3 - 0
internal/web/translation/uk-UA.json

@@ -874,6 +874,8 @@
       },
       "renewMax": "Ліміт подовжень",
       "renewMaxDesc": "Скільки разів автоподовження може спрацювати, перш ніж клієнта буде залишено спливати. 0 — без обмеження. Надолуження кількох пропущених періодів витрачає по одному подовженню на період.",
+      "renewOnDay": "Подовжувати числа",
+      "renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.",
       "renewsUsed": "Подовжень витрачено"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "Закінчення як мітка часу Unix (секунди)",
         "descCREATED_UNIX": "Час створення як мітка часу Unix (секунди)",
         "descRESET_DAYS": "Період скидання трафіку в днях",
+        "descRESET_DAY": "Число місяця, у яке подовжується доступ",
         "descPROTOCOL": "Протокол вхідного (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Транспортна мережа (tcp, ws, grpc, …)",
         "descSECURITY": "Безпека транспорту (TLS, REALITY, NONE)"

+ 3 - 0
internal/web/translation/vi-VN.json

@@ -874,6 +874,8 @@
       },
       "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ỳ.",
+      "renewOnDay": "Gia hạn vào ngày",
+      "renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.",
       "renewsUsed": "Số lần gia hạn đã dùng"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "Hết hạn dạng dấu thời gian Unix (giây)",
         "descCREATED_UNIX": "Thời điểm tạo dạng dấu thời gian Unix (giây)",
         "descRESET_DAYS": "Chu kỳ đặt lại lưu lượng tính theo ngày",
+        "descRESET_DAY": "Ngày trong tháng để gia hạn",
         "descPROTOCOL": "Giao thức inbound (VLESS, VMess, Trojan, …)",
         "descTRANSPORT": "Mạng truyền tải (tcp, ws, grpc, …)",
         "descSECURITY": "Bảo mật truyền tải (TLS, REALITY, NONE)"

+ 3 - 0
internal/web/translation/zh-CN.json

@@ -874,6 +874,8 @@
       },
       "renewMax": "最大续期次数",
       "renewMaxDesc": "自动续期最多可触发的次数,达到后客户端将自然到期。填 0 表示不限制。补齐多个错过的周期时,每个周期消耗一次续期。",
+      "renewOnDay": "按日期续期",
+      "renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。",
       "renewsUsed": "已用续期次数"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "到期时间的 Unix 时间戳(秒)",
         "descCREATED_UNIX": "创建时间的 Unix 时间戳(秒)",
         "descRESET_DAYS": "流量重置周期(天)",
+        "descRESET_DAY": "按月续期的日期",
         "descPROTOCOL": "入站协议(VLESS、VMess、Trojan……)",
         "descTRANSPORT": "传输网络(tcp、ws、grpc……)",
         "descSECURITY": "传输安全(TLS、REALITY、NONE)"

+ 3 - 0
internal/web/translation/zh-TW.json

@@ -874,6 +874,8 @@
       },
       "renewMax": "最大續期次數",
       "renewMaxDesc": "自動續期最多可觸發的次數,達到後用戶端將自然到期。填 0 表示不限制。補齊多個錯過的週期時,每個週期消耗一次續期。",
+      "renewOnDay": "按日期續期",
+      "renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。填 0 保持按天間隔模式。",
       "renewsUsed": "已用續期次數"
     },
     "groups": {
@@ -1886,6 +1888,7 @@
         "descEXPIRE_UNIX": "到期時間(Unix 時間戳記,秒)",
         "descCREATED_UNIX": "建立時間(Unix 時間戳記,秒)",
         "descRESET_DAYS": "流量重置週期(天)",
+        "descRESET_DAY": "按月續期的日期",
         "descPROTOCOL": "入站協定(VLESS、VMess、Trojan…)",
         "descTRANSPORT": "傳輸網路(tcp、ws、grpc…)",
         "descSECURITY": "傳輸安全(TLS、REALITY、NONE)"

+ 3 - 0
internal/xray/client_traffic.go

@@ -14,6 +14,9 @@ type ClientTraffic struct {
 	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"`
+	// ResetDay renews on that day of each calendar month instead of every
+	// Reset days; 0 keeps the interval behaviour.
+	ResetDay int `json:"resetDay" form:"resetDay" gorm:"default:0" 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.