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
+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