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