feat(sub): add opt-in month-end expiry presentation (#6517)

Offer monthly calendar subscriptions an explicit last-valid-second display
without moving their real billing boundary or spending renewal allowances.

Keep the option off by default and limit conversion to a shared fixed
day-1 midnight cutoff at an actual month transition in the panel timezone.
Use the authoritative client calendar mode when aggregating node traffic,
and share the header formatter across raw, JSON, and Clash exports.

Expose the setting in the existing settings API/UI, regenerate its schemas,
and document that clients may report expiry one second early or format the
date differently in another timezone. Add HTTP, settings, and DST coverage.
Stored deadlines, access enforcement, info/remark expiry values, and renewal
accounting remain unchanged.

Refs: #6516

Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
This commit is contained in:
Jack
2026-09-14 18:10:25 +08:00
committed by GitHub
parent 826e29e2de
commit c90996eda3
32 changed files with 412 additions and 8 deletions
@@ -94,6 +94,31 @@ Subscriptions return standard headers that compatible apps read:
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
**`Announce`** — optional branding shown by some clients.
### Optional month-end expiry display
Under **Subscription → Information**, **Month-end subscription expiry display**
(`subCalendarExpireInclusive`, default `false`) reports the last valid second
of the month in `Subscription-Userinfo` instead of the next month's midnight.
It applies only when every client contributing to the subscription has calendar
renewal day `1`, shares the same fixed expiry, and that expiry is exactly day `1`
at `00:00:00` in the configured panel timezone, immediately after the previous
month's last second. A later repeated midnight during a DST rollback is not
converted. Raw, JSON, Mihomo, and legacy
Clash subscriptions use the same conversion.
For example, the real cutoff `2030-10-01 00:00:00` is presented as
`2030-09-30 23:59:59`. The stored expiry, access cutoff, traffic accounting,
renewal schedule, remark expiry variables, and HTML/JSON info-page cutoff stay
unchanged. Arbitrary times, other renewal days, interval renewal, first-use
durations, unlimited expiries, mixed renewal modes, and different cutoffs are
not converted.
This is an opt-in compatibility tradeoff, not a change to expiry semantics by
default: apps receive a timestamp one second before the real cutoff and may
consider the subscription expired one second early. Apps format it in their own
timezone; matching the panel timezone is needed to display the same month-end
date. Cached subscription information changes only after the app refreshes it.
## Custom page templates
Point `subThemeDir` at a folder containing a custom info-page template to brand
+8
View File
@@ -221,6 +221,9 @@
"subAnnounce": {
"type": "string"
},
"subCalendarExpireInclusive": {
"type": "boolean"
},
"subCertFile": {
"type": "string"
},
@@ -558,6 +561,7 @@
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCalendarExpireInclusive",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",
@@ -868,6 +872,9 @@
"subAnnounce": {
"type": "string"
},
"subCalendarExpireInclusive": {
"type": "boolean"
},
"subCertFile": {
"type": "string"
},
@@ -1213,6 +1220,7 @@
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCalendarExpireInclusive",
"subCertFile",
"subClashAutoDetect",
"subClashEnable",