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 <n0ctal@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
n0ctal
2026-08-18 15:46:00 +05:00
committed by GitHub
parent 1872659d83
commit b8903fadf4
34 changed files with 730 additions and 10 deletions
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
@@ -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' },
@@ -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')}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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":
View File
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.