فهرست منبع

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 <[email protected]>
Jack 5 ساعت پیش
والد
کامیت
c90996eda3
32فایلهای تغییر یافته به همراه412 افزوده شده و 8 حذف شده
  1. 25 0
      docs/content/docs/en/config/subscription.mdx
  2. 8 0
      docs/public/openapi.json
  3. 8 0
      frontend/public/openapi.json
  4. 2 0
      frontend/src/generated/examples.ts
  5. 8 0
      frontend/src/generated/schemas.ts
  6. 2 0
      frontend/src/generated/types.ts
  7. 2 0
      frontend/src/generated/zod.ts
  8. 1 0
      frontend/src/models/setting.ts
  9. 11 0
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  10. 1 0
      frontend/src/schemas/setting.ts
  11. 27 0
      frontend/src/test/calendar-expire-setting.test.tsx
  12. 21 0
      internal/sub/calendar_expire.go
  13. 241 0
      internal/sub/calendar_expire_test.go
  14. 1 1
      internal/sub/clash_service.go
  15. 1 1
      internal/sub/controller.go
  16. 1 1
      internal/sub/json_service.go
  17. 20 5
      internal/sub/service.go
  18. 1 0
      internal/web/entity/entity.go
  19. 5 0
      internal/web/service/setting.go
  20. 2 0
      internal/web/translation/ar-EG.json
  21. 2 0
      internal/web/translation/en-US.json
  22. 2 0
      internal/web/translation/es-ES.json
  23. 2 0
      internal/web/translation/fa-IR.json
  24. 2 0
      internal/web/translation/id-ID.json
  25. 2 0
      internal/web/translation/ja-JP.json
  26. 2 0
      internal/web/translation/pt-BR.json
  27. 2 0
      internal/web/translation/ru-RU.json
  28. 2 0
      internal/web/translation/tr-TR.json
  29. 2 0
      internal/web/translation/uk-UA.json
  30. 2 0
      internal/web/translation/vi-VN.json
  31. 2 0
      internal/web/translation/zh-CN.json
  32. 2 0
      internal/web/translation/zh-TW.json

+ 25 - 0
docs/content/docs/en/config/subscription.mdx

@@ -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 - 0
docs/public/openapi.json

@@ -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",

+ 8 - 0
frontend/public/openapi.json

@@ -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",

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

@@ -58,6 +58,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpTo": "",
     "smtpUsername": "",
     "subAnnounce": "",
+    "subCalendarExpireInclusive": false,
     "subCertFile": "",
     "subClashAutoDetect": false,
     "subClashEnable": false,
@@ -213,6 +214,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpTo": "",
     "smtpUsername": "",
     "subAnnounce": "",
+    "subCalendarExpireInclusive": false,
     "subCertFile": "",
     "subClashAutoDetect": false,
     "subClashEnable": false,

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

@@ -195,6 +195,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subAnnounce": {
         "type": "string"
       },
+      "subCalendarExpireInclusive": {
+        "type": "boolean"
+      },
       "subCertFile": {
         "type": "string"
       },
@@ -532,6 +535,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpTo",
       "smtpUsername",
       "subAnnounce",
+      "subCalendarExpireInclusive",
       "subCertFile",
       "subClashAutoDetect",
       "subClashEnable",
@@ -842,6 +846,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subAnnounce": {
         "type": "string"
       },
+      "subCalendarExpireInclusive": {
+        "type": "boolean"
+      },
       "subCertFile": {
         "type": "string"
       },
@@ -1187,6 +1194,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpTo",
       "smtpUsername",
       "subAnnounce",
+      "subCalendarExpireInclusive",
       "subCertFile",
       "subClashAutoDetect",
       "subClashEnable",

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

@@ -65,6 +65,7 @@ export interface AllSetting {
   smtpTo: string;
   smtpUsername: string;
   subAnnounce: string;
+  subCalendarExpireInclusive: boolean;
   subCertFile: string;
   subClashAutoDetect: boolean;
   subClashEnable: boolean;
@@ -221,6 +222,7 @@ export interface AllSettingView {
   smtpTo: string;
   smtpUsername: string;
   subAnnounce: string;
+  subCalendarExpireInclusive: boolean;
   subCertFile: string;
   subClashAutoDetect: boolean;
   subClashEnable: boolean;

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

@@ -79,6 +79,7 @@ export const AllSettingSchema = z.object({
   smtpTo: z.string(),
   smtpUsername: z.string(),
   subAnnounce: z.string(),
+  subCalendarExpireInclusive: z.boolean(),
   subCertFile: z.string(),
   subClashAutoDetect: z.boolean(),
   subClashEnable: z.boolean(),
@@ -236,6 +237,7 @@ export const AllSettingViewSchema = z.object({
   smtpTo: z.string(),
   smtpUsername: z.string(),
   subAnnounce: z.string(),
+  subCalendarExpireInclusive: z.boolean(),
   subCertFile: z.string(),
   subClashAutoDetect: z.boolean(),
   subClashEnable: z.boolean(),

+ 1 - 0
frontend/src/models/setting.ts

@@ -19,6 +19,7 @@ export class AllSetting {
   remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
   subShowIdentityOnAllLinks = false;
   subInfoNodeEnable = false;
+  subCalendarExpireInclusive = false;
   subExpiredTemplate = '⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}';
   subTrafficDepletedTemplate =
     '🚫 {{EMAIL}} | Traffic Depleted | {{TRAFFIC_USED}}/{{TRAFFIC_TOTAL}}';

+ 11 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -197,6 +197,17 @@ export default function SubscriptionGeneralTab({
                 />
               </SettingListItem>
 
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.subCalendarExpireInclusive')}
+                description={t('pages.settings.subCalendarExpireInclusiveDesc')}
+              >
+                <Switch
+                  checked={allSetting.subCalendarExpireInclusive}
+                  onChange={(v) => updateSetting({ subCalendarExpireInclusive: v })}
+                />
+              </SettingListItem>
+
               <SettingListItem
                 paddings="small"
                 title={t('pages.settings.subExpiredTemplate')}

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -23,6 +23,7 @@ export const AllSettingSchema = z
     remarkTemplate: z.string().optional(),
     subShowIdentityOnAllLinks: z.boolean().optional(),
     subInfoNodeEnable: z.boolean().optional(),
+    subCalendarExpireInclusive: z.boolean().optional(),
     subExpiredTemplate: z.string().optional(),
     subTrafficDepletedTemplate: z.string().optional(),
     datepicker: z.enum(['gregorian', 'jalalian']).optional(),

+ 27 - 0
frontend/src/test/calendar-expire-setting.test.tsx

@@ -0,0 +1,27 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AllSetting } from '@/models/setting';
+import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
+import { renderWithProviders } from './test-utils';
+
+describe('calendar expiry presentation setting', () => {
+  it('is off by default and updates only the presentation option', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings#subscription']}>
+        <SubscriptionGeneralTab allSetting={new AllSetting()} updateSetting={updateSetting} />
+      </MemoryRouter>,
+    );
+
+    fireEvent.click(screen.getByRole('tab', { name: /Information/ }));
+    const toggle = screen.getByRole('switch', { name: 'Month-end subscription expiry display' });
+    expect(toggle.getAttribute('aria-checked')).toBe('false');
+    expect(updateSetting).not.toHaveBeenCalled();
+    fireEvent.click(toggle);
+
+    expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subCalendarExpireInclusive: true });
+  });
+});

+ 21 - 0
internal/sub/calendar_expire.go

@@ -0,0 +1,21 @@
+package sub
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func (s *SubService) subscriptionUserinfo(traffic xray.ClientTraffic) string {
+	expire := traffic.ExpiryTime / 1000
+	if s.subCalendarExpireInclusive && traffic.ResetDay == 1 && traffic.ExpiryTime > 0 && s.calendarExpireLocation != nil {
+		at := time.UnixMilli(traffic.ExpiryTime).In(s.calendarExpireLocation)
+		midnight := at.Day() == 1 && at.Hour() == 0 && at.Minute() == 0 && at.Second() == 0 && at.Nanosecond() == 0
+		if midnight && at.Add(-time.Second).Month() != at.Month() {
+			// Opt-in last-valid-second presentation; never change the real cutoff (#6516).
+			expire--
+		}
+	}
+	return fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, expire)
+}

+ 241 - 0
internal/sub/calendar_expire_test.go

@@ -0,0 +1,241 @@
+package sub
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestSubscriptionCalendarExpireInclusive(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	tests := []struct {
+		name       string
+		setting    string
+		zone       string
+		boundary   string
+		resetDay   int
+		trafficDay int
+		wantSnap   bool
+	}{
+		{"default unchanged", "", "UTC", "2030-10-01T00:00:00Z", 1, 1, false},
+		{"disabled unchanged", "false", "UTC", "2030-10-01T00:00:00Z", 1, 1, false},
+		{"30 day month", "true", "UTC", "2030-10-01T00:00:00Z", 1, 1, true},
+		{"31 day month", "true", "UTC", "2030-11-01T00:00:00Z", 1, 1, true},
+		{"non leap February", "true", "UTC", "2030-03-01T00:00:00Z", 1, 1, true},
+		{"leap February", "true", "UTC", "2028-03-01T00:00:00Z", 1, 1, true},
+		{"Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00+08:00", 1, 1, true},
+		{"New York daylight time", "true", "America/New_York", "2030-10-01T00:00:00-04:00", 1, 1, true},
+		{"New York standard time", "true", "America/New_York", "2030-02-01T00:00:00-05:00", 1, 1, true},
+		{"Havana first midnight", "true", "America/Havana", "2026-11-01T00:00:00-04:00", 1, 1, true},
+		{"Havana repeated midnight", "true", "America/Havana", "2026-11-01T00:00:00-05:00", 1, 1, false},
+		{"UTC midnight is not Taipei midnight", "true", "Asia/Taipei", "2030-10-01T00:00:00Z", 1, 1, false},
+		{"midday unchanged", "true", "UTC", "2030-10-01T12:00:00Z", 1, 1, false},
+		{"other midnight unchanged", "true", "UTC", "2030-10-02T00:00:00Z", 1, 1, false},
+		{"fractional midnight unchanged", "true", "UTC", "2030-10-01T00:00:00.001Z", 1, 1, false},
+		{"legacy inclusive input unchanged", "true", "UTC", "2030-09-30T23:59:59Z", 1, 1, false},
+		{"interval renewal unchanged", "true", "UTC", "2030-10-01T00:00:00Z", 0, 0, false},
+		{"other billing day unchanged", "true", "UTC", "2030-10-01T00:00:00Z", 31, 31, false},
+		{"client calendar overrides stale traffic", "true", "UTC", "2030-10-01T00:00:00Z", 1, 0, true},
+		{"client interval overrides stale traffic", "true", "UTC", "2030-10-01T00:00:00Z", 0, 1, false},
+		{"unlimited unchanged", "true", "UTC", "", 1, 1, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			seedSubDB(t)
+			db := database.GetDB()
+			if err := db.Create(&model.Setting{Key: "timeLocation", Value: tt.zone}).Error; err != nil {
+				t.Fatal(err)
+			}
+			if tt.setting != "" {
+				if err := db.Create(&model.Setting{Key: "subCalendarExpireInclusive", Value: tt.setting}).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			var expiry int64
+			if tt.boundary != "" {
+				at, err := time.Parse(time.RFC3339Nano, tt.boundary)
+				if err != nil {
+					t.Fatal(err)
+				}
+				expiry = at.UnixMilli()
+			}
+			seedSubProtocolInbound(t, "calendar", "monthly", 4931, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
+			if err := db.Model(&model.ClientRecord{}).Where("email = ?", "monthly@e").Updates(map[string]any{
+				"expiry_time": expiry, "reset_day": tt.resetDay,
+			}).Error; err != nil {
+				t.Fatal(err)
+			}
+			// Node snapshots may omit limits; the clients table still owns the calendar.
+			if err := db.Create(&xray.ClientTraffic{
+				Email: "monthly@e", Enable: true, Up: 11, Down: 22, ResetDay: tt.trafficDay, ResetCount: 7,
+			}).Error; err != nil {
+				t.Fatal(err)
+			}
+			router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+			wantExpiry := expiry / 1000
+			if tt.wantSnap {
+				wantExpiry--
+			}
+			wantHeader := fmt.Sprintf("upload=11; download=22; total=0; expire=%d", wantExpiry)
+			for _, path := range []string{"/sub/calendar", "/json/calendar", "/clash/calendar", "/mihomo/calendar", "/clash-legacy/calendar"} {
+				resp := httptest.NewRecorder()
+				router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil))
+				if resp.Code != http.StatusOK {
+					t.Fatalf("GET %s: status=%d body=%s", path, resp.Code, resp.Body.String())
+				}
+				if got := resp.Header().Get("Subscription-Userinfo"); got != wantHeader {
+					t.Fatalf("GET %s: userinfo=%q, want %q", path, got, wantHeader)
+				}
+			}
+
+			resp := httptest.NewRecorder()
+			router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/calendar?format=info", nil))
+			var info struct {
+				Expire int64 `json:"expire"`
+			}
+			if resp.Code != http.StatusOK {
+				t.Fatalf("info status=%d body=%s", resp.Code, resp.Body.String())
+			}
+			if err := json.Unmarshal(resp.Body.Bytes(), &info); err != nil {
+				t.Fatal(err)
+			}
+			if info.Expire != expiry/1000 {
+				t.Fatalf("info cutoff=%d, want canonical %d", info.Expire, expiry/1000)
+			}
+			var client model.ClientRecord
+			var traffic xray.ClientTraffic
+			if err := db.Where("email = ?", "monthly@e").First(&client).Error; err != nil {
+				t.Fatal(err)
+			}
+			if err := db.Where("email = ?", "monthly@e").First(&traffic).Error; err != nil {
+				t.Fatal(err)
+			}
+			if client.ExpiryTime != expiry || traffic.ResetCount != 7 || traffic.Up != 11 || traffic.Down != 22 {
+				t.Fatalf("subscription presentation mutated scheduling/accounting: client=%+v traffic=%+v", client, traffic)
+			}
+		})
+	}
+}
+
+func TestSubscriptionCalendarExpireInclusiveMixedClients(t *testing.T) {
+	tests := []struct {
+		name       string
+		days       [2]int
+		different  bool
+		wantExpiry int64
+	}{
+		{"calendar then interval", [2]int{1, 0}, false, 1917043200},
+		{"interval then calendar", [2]int{0, 1}, false, 1917043200},
+		{"same calendar", [2]int{1, 1}, false, 1917043199},
+		{"different cutoffs", [2]int{1, 1}, true, 0},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			seedSubDB(t)
+			db := database.GetDB()
+			for key, value := range map[string]string{"timeLocation": "UTC", "subCalendarExpireInclusive": "true"} {
+				if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			const expiry = int64(1917043200000) // 2030-10-01 00:00:00 UTC
+			for i, day := range tt.days {
+				tag := fmt.Sprintf("client%d", i)
+				seedSubInbound(t, "mixed", tag, 4932+i, i, `{"network":"tcp","security":"none"}`)
+				clientExpiry := expiry
+				if i == 1 && tt.different {
+					clientExpiry += 31 * 24 * time.Hour.Milliseconds()
+				}
+				if err := db.Model(&model.ClientRecord{}).Where("email = ?", tag+"@e").Updates(map[string]any{
+					"expiry_time": clientExpiry, "reset_day": day,
+				}).Error; err != nil {
+					t.Fatal(err)
+				}
+				if err := db.Create(&xray.ClientTraffic{Email: tag + "@e", Enable: true}).Error; err != nil {
+					t.Fatal(err)
+				}
+			}
+			router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+			for _, path := range []string{"/sub/mixed", "/json/mixed", "/clash/mixed"} {
+				resp := httptest.NewRecorder()
+				router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil))
+				if resp.Code != http.StatusOK || !strings.HasSuffix(resp.Header().Get("Subscription-Userinfo"), fmt.Sprintf("expire=%d", tt.wantExpiry)) {
+					t.Fatalf("GET %s: status=%d userinfo=%q, want expire=%d", path, resp.Code, resp.Header().Get("Subscription-Userinfo"), tt.wantExpiry)
+				}
+			}
+		})
+	}
+}
+
+func TestSubscriptionCalendarExpireInclusiveSettingRoundTrip(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	seedSubInbound(t, "toggle", "monthly", 4935, 1, `{"network":"tcp","security":"none"}`)
+	const expiry = int64(1917043200000)
+	if err := db.Model(&model.ClientRecord{}).Where("email = ?", "monthly@e").Updates(map[string]any{
+		"expiry_time": expiry, "reset_day": 1,
+	}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{Email: "monthly@e", Enable: true}).Error; err != nil {
+		t.Fatal(err)
+	}
+	settings := &service.SettingService{}
+	all, err := settings.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if all.SubCalendarExpireInclusive {
+		t.Fatal("inclusive presentation must default off")
+	}
+	all.TimeLocation = "UTC"
+	router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+	for _, enabled := range []bool{false, true, false} {
+		all.SubCalendarExpireInclusive = enabled
+		if err := settings.UpdateAllSetting(all, service.SecretClears{}); err != nil {
+			t.Fatal(err)
+		}
+		stored, err := settings.GetAllSetting()
+		if err != nil || stored.SubCalendarExpireInclusive != enabled {
+			t.Fatalf("setting round trip: enabled=%v stored=%+v err=%v", enabled, stored, err)
+		}
+		resp := httptest.NewRecorder()
+		router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/toggle", nil))
+		want := expiry / 1000
+		if enabled {
+			want--
+		}
+		if got := resp.Header().Get("Subscription-Userinfo"); resp.Code != http.StatusOK || got != fmt.Sprintf("upload=0; download=0; total=0; expire=%d", want) {
+			t.Fatalf("enabled=%v: status=%d userinfo=%q, want expire=%d", enabled, resp.Code, got, want)
+		}
+	}
+}
+
+func TestSubscriptionCalendarExpireInclusiveFirstUseDuration(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+	const duration = -int64(24 * time.Hour / time.Millisecond)
+	if err := db.Create(&model.ClientRecord{Email: "first-use@e", ExpiryTime: duration, ResetDay: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{Email: "first-use@e", ExpiryTime: duration, ResetDay: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	before := time.Now().UnixMilli()
+	agg, _ := (&SubService{}).AggregateTrafficByEmails([]string{"first-use@e"})
+	after := time.Now().UnixMilli()
+	if agg.ResetDay != 0 || agg.ExpiryTime < before-duration || agg.ExpiryTime > after-duration {
+		t.Fatalf("first-use duration must not become a canonical calendar cutoff: %+v", agg)
+	}
+}

+ 1 - 1
internal/sub/clash_service.go

@@ -113,7 +113,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
 	slices.Sort(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 	traffic.Enable = hasEnabledClient
-	header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+	header := subReq.subscriptionUserinfo(traffic)
 
 	if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(proxies) > 0); mode != infoNodeNone {
 		dummyProxy := map[string]any{

+ 1 - 1
internal/sub/controller.go

@@ -484,7 +484,7 @@ func (a *SUBController) subs(c *gin.Context) {
 		}
 
 		// Add headers
-		header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+		header := subReq.subscriptionUserinfo(traffic)
 		profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 		metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
 		a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)

+ 1 - 1
internal/sub/json_service.go

@@ -220,7 +220,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 	slices.Sort(emails)
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 	traffic.Enable = hasEnabledClient
-	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+	header = subReq.subscriptionUserinfo(traffic)
 
 	if mode, remark := subReq.resolveInfoNodeRemark(subId, emails, traffic, len(configArray) > 0); mode != infoNodeNone {
 		dummyConfig := s.genDummySocksConfig(remark)

+ 20 - 5
internal/sub/service.go

@@ -48,6 +48,8 @@ type SubService struct {
 	usageShown                 map[string]bool
 	showIdentityOnAllLinks     bool
 	subInfoNodeEnable          bool
+	subCalendarExpireInclusive bool
+	calendarExpireLocation     *time.Location
 	subExpiredTemplate         string
 	subTrafficDepletedTemplate string
 	inboundService             service.InboundService
@@ -111,6 +113,11 @@ func (s *SubService) PrepareForRequest(host string) {
 	s.settingsByInbound = map[int]map[string]any{}
 	s.loadNodes()
 	s.loadRemarkSettings()
+	s.subCalendarExpireInclusive, _ = s.settingService.GetSubCalendarExpireInclusive()
+	s.calendarExpireLocation = nil
+	if s.subCalendarExpireInclusive {
+		s.calendarExpireLocation, _ = s.settingService.GetTimeLocation()
+	}
 }
 
 // primeLinkClients caches clients (first occurrence per email, matching the
@@ -544,13 +551,13 @@ func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraff
 	// runtime traffic rows. In a multi-node setup the node snapshot can reset
 	// client_traffics.total/expiry_time to 0, so fall back to the clients
 	// table to keep the Subscription-Userinfo header in sync with the UI (#4645).
-	limits := make(map[string][2]int64, len(emails))
+	limits := make(map[string]model.ClientRecord, len(emails))
 	var records []model.ClientRecord
 	if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Find(&records).Error; err != nil {
 		logger.Warning("SubService - AggregateTrafficByEmails: load client limits:", err)
 	} else {
 		for _, r := range records {
-			limits[r.Email] = [2]int64{r.TotalGB, r.ExpiryTime}
+			limits[r.Email] = r
 		}
 	}
 
@@ -560,25 +567,33 @@ func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraff
 		if ct.LastOnline > lastOnline {
 			lastOnline = ct.LastOnline
 		}
-		total, expiry := ct.Total, ct.ExpiryTime
+		total, expiry, resetDay := ct.Total, ct.ExpiryTime, ct.ResetDay
 		if lim, ok := limits[ct.Email]; ok {
+			resetDay = lim.ResetDay
 			if total == 0 {
-				total = lim[0]
+				total = lim.TotalGB
 			}
 			if expiry == 0 {
-				expiry = lim[1]
+				expiry = lim.ExpiryTime
 			}
 		}
+		if expiry <= 0 {
+			resetDay = 0
+		}
 		if first {
 			agg.Up = ct.Up
 			agg.Down = ct.Down
 			agg.Total = total
 			agg.ExpiryTime = subscriptionExpiryFromClient(now, expiry)
+			agg.ResetDay = resetDay
 			first = false
 			continue
 		}
 		agg.Up += ct.Up
 		agg.Down += ct.Down
+		if resetDay != agg.ResetDay {
+			agg.ResetDay = 0
+		}
 		if agg.Total == 0 || total == 0 {
 			agg.Total = 0
 		} else {

+ 1 - 0
internal/web/entity/entity.go

@@ -37,6 +37,7 @@ type AllSetting struct {
 	RemarkTemplate             string `json:"remarkTemplate" form:"remarkTemplate"`
 	SubShowIdentityOnAllLinks  bool   `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
 	SubInfoNodeEnable          bool   `json:"subInfoNodeEnable" form:"subInfoNodeEnable"`
+	SubCalendarExpireInclusive bool   `json:"subCalendarExpireInclusive" form:"subCalendarExpireInclusive"`
 	SubExpiredTemplate         string `json:"subExpiredTemplate" form:"subExpiredTemplate"`
 	SubTrafficDepletedTemplate string `json:"subTrafficDepletedTemplate" form:"subTrafficDepletedTemplate"`
 	Datepicker                 string `json:"datepicker" form:"datepicker"`

+ 5 - 0
internal/web/service/setting.go

@@ -75,6 +75,7 @@ var defaultValueMap = map[string]string{
 	"remarkTemplate":              DefaultRemarkTemplate,
 	"subShowIdentityOnAllLinks":   "false",
 	"subInfoNodeEnable":           "false",
+	"subCalendarExpireInclusive":  "false",
 	"subExpiredTemplate":          DefaultSubExpiredTemplate,
 	"subTrafficDepletedTemplate":  DefaultSubTrafficDepletedTemplate,
 	"timeLocation":                "Local",
@@ -722,6 +723,10 @@ func (s *SettingService) GetSubInfoNodeEnable() (bool, error) {
 	return s.getBool("subInfoNodeEnable")
 }
 
+func (s *SettingService) GetSubCalendarExpireInclusive() (bool, error) {
+	return s.getBool("subCalendarExpireInclusive")
+}
+
 func (s *SettingService) GetSubExpiredTemplate() (string, error) {
 	return s.getString("subExpiredTemplate")
 }

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

@@ -1493,6 +1493,8 @@
           "note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "تكوين معلومات منفصل / عقدة وهمية",
       "subInfoNodeEnableDesc": "عند التفعيل، يتم عرض الملاحظات وحركة المرور/الأيام المتبقية كتكوين SOCKS منفصل في الأعلى، وعند انتهاء الصلاحية أو نفاد البيانات يتم إرجاع تكوين الحالة فقط.",
       "subExpiredTemplate": "قالب انتهاء الصلاحية",

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

@@ -1611,6 +1611,8 @@
           "note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Separate Info Config / Dummy Node",
       "subInfoNodeEnableDesc": "When enabled, remarks and remaining traffic/days are served as a separate dummy SOCKS config at the top, and expired/depleted subscriptions return only the status config.",
       "subExpiredTemplate": "Expired Template",

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

@@ -1493,6 +1493,8 @@
           "note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Configuración de información separada / Nodo ficticio",
       "subInfoNodeEnableDesc": "Si está activado, la información de tráfico y días restantes se muestra en un nodo SOCKS ficticio arriba, y las suscripciones caducadas o agotadas devuelven solo el estado.",
       "subExpiredTemplate": "Plantilla de expirado",

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

@@ -1493,6 +1493,8 @@
           "note": "موزان‌کننده‌های leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی می‌کند — آن را خاموش کنید تا از پیش‌فرض‌های داخلی استفاده شود. تغییرات پس از راه‌اندازی مجدد پنل اعمال می‌شوند."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "کانفیگ جداگانه اطلاعات / نود نمایشی",
       "subInfoNodeEnableDesc": "در صورت فعال بودن، مشخصات و حجم/روزهای باقیمانده به عنوان یک کانفیگ مجزای ساکس در بالای لیست نمایش داده می‌شود و در صورت انقضا یا اتمام حجم فقط پیام وضعیت ارسال می‌شود.",
       "subExpiredTemplate": "قالب پیام انقضا",

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

@@ -1493,6 +1493,8 @@
           "note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Konfigurasi Info Terpisah / Node Dummy",
       "subInfoNodeEnableDesc": "Saat diaktifkan, catatan dan sisa kuota/hari disajikan sebagai konfigurasi SOCKS dummy terpisah di bagian atas, dan langganan kedaluwarsa/habis hanya mengembalikan status.",
       "subExpiredTemplate": "Templat Kedaluwarsa",

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

@@ -1493,6 +1493,8 @@
           "note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。"
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "個別情報設定 / ダミーノード",
       "subInfoNodeEnableDesc": "有効にすると、備考と残りの通信量/日数が上部の独立したSOCKSダミー構成として表示され、期限切れや通信量超過時はステータスのみが返されます。",
       "subExpiredTemplate": "期限切れテンプレート",

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

@@ -1493,6 +1493,8 @@
           "note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Configuração de informações separada / Nó fictício",
       "subInfoNodeEnableDesc": "Quando ativado, observações e tráfego/dias restantes são exibidos em uma configuração SOCKS fictícia no topo, e assinaturas expiradas/esgotadas retornam apenas o status.",
       "subExpiredTemplate": "Modelo expirado",

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

@@ -1493,6 +1493,8 @@
           "note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Отдельный инфо-конфиг / фиктивный узел",
       "subInfoNodeEnableDesc": "Если включено, примечание и остаток трафика/дней отображаются отдельным SOCKS-конфигом вверху, а при истечении срока/трафика возвращается только статус.",
       "subExpiredTemplate": "Шаблон истекшей подписки",

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

@@ -1493,6 +1493,8 @@
           "note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Ayrı Bilgi Yapılandırması / Sahte Düğüm",
       "subInfoNodeEnableDesc": "Etkinleştirildiğinde, notlar ve kalan trafik/günler en üstte ayrı bir sahte SOCKS yapılandırması olarak sunulur; süresi dolmuş veya kotası bitmiş abonelikler yalnızca durum yapılandırmasını alır.",
       "subExpiredTemplate": "Süresi Dolmuş Şablonu",

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

@@ -1493,6 +1493,8 @@
           "note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Окремий інфо-конфіг / фіктивний вузол",
       "subInfoNodeEnableDesc": "Якщо увімкнено, примітка та залишок трафіку/днів відображаються окремим SOCKS-конфігом угорі, а після закінчення терміну/трафіку повертається лише статус.",
       "subExpiredTemplate": "Шаблон закінчення терміну",

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

@@ -1493,6 +1493,8 @@
           "note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển."
         }
       },
+      "subCalendarExpireInclusive": "Month-end subscription expiry display",
+      "subCalendarExpireInclusiveDesc": "Default off. For clients renewing on day 1 with a midnight cutoff in the panel timezone, report the previous month's last second in subscription expiry headers. Actual cutoff and renewals are unchanged. Clients use their own timezone and may consider the subscription expired one second early.",
       "subInfoNodeEnable": "Cấu hình thông tin riêng / Nút ảo",
       "subInfoNodeEnableDesc": "Khi bật, ghi chú và lưu lượng/ngày còn lại sẽ hiển thị dưới dạng cấu hình SOCKS ảo riêng ở trên cùng, và đăng ký hết hạn/hết dung lượng chỉ trả về cấu hình trạng thái.",
       "subExpiredTemplate": "Mẫu hết hạn",

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

@@ -1493,6 +1493,8 @@
           "note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。"
         }
       },
+      "subCalendarExpireInclusive": "订阅到期日期显示为月底",
+      "subCalendarExpireInclusiveDesc": "默认关闭。对于按每月 1 日续期、且到期时间为面板时区午夜的客户端,订阅到期时间报告为上月最后一秒。真实截止时间和续期不变。客户端使用自身时区,可能提前一秒显示过期。",
       "subInfoNodeEnable": "独立信息配置 / 提示节点",
       "subInfoNodeEnableDesc": "启用后,备注及剩余流量/天数将作为单独的 SOCKS 提示配置显示在顶部;过期或流量耗尽时仅返回状态提示。",
       "subExpiredTemplate": "过期提示模板",

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

@@ -1493,6 +1493,8 @@
           "note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。"
         }
       },
+      "subCalendarExpireInclusive": "訂閱到期日期顯示為月底",
+      "subCalendarExpireInclusiveDesc": "預設關閉。對於按每月 1 日續期、且到期時間為面板時區午夜的用戶端,訂閱到期時間回報為上月最後一秒。真實截止時間和續期不變。用戶端使用自身時區,可能提前一秒顯示過期。",
       "subInfoNodeEnable": "獨立資訊配置 / 提示節點",
       "subInfoNodeEnableDesc": "啟用後,備註及剩餘流量/天數將作為單獨的 SOCKS 提示配置顯示在頂部;過期或流量耗盡時僅返回狀態提示。",
       "subExpiredTemplate": "過期提示範本",