From 5cce2464f11fd3e922b48cf7d4487ea85e537fb2 Mon Sep 17 00:00:00 2001 From: mrchatam Date: Sat, 12 Sep 2026 12:36:47 +0330 Subject: [PATCH] fix(clients): snap EOM 23:59:59 expiry to billing midnight without renew (#6457) * fix(clients): snap EOM 23:59:59 expiry to billing midnight without renew (#6300) Inclusive end-of-month expiries share the next calendar billing boundary. Normalize onto that midnight before the catch-up loop so the first charged step is a full month and resetMax=1 is not spent on a one-second alignment. * fix(clients): snap only the EOM 23:59:59 instant, not the whole prior day The calendar renew guard was matching [boundary-1d, boundary), so a midday expiry on the day before billing could be snapped past now with renewals=0 and left disabled until midnight. Narrow to [boundary-1s, boundary). Also clear golangci (gofumpt/QF1001), trim comments to 2 lines, and pin that midday expiry still charges for alignment. --------- Co-authored-by: mrchatam Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> --- .../inbound_autorenew_calendar_test.go | 142 ++++++++++++++++++ internal/web/service/inbound_traffic.go | 9 ++ 2 files changed, 151 insertions(+) diff --git a/internal/web/service/inbound_autorenew_calendar_test.go b/internal/web/service/inbound_autorenew_calendar_test.go index d48aa6b39..71a963a03 100644 --- a/internal/web/service/inbound_autorenew_calendar_test.go +++ b/internal/web/service/inbound_autorenew_calendar_test.go @@ -389,3 +389,145 @@ func TestAutoRenewClients_CalendarModeSpendsOneAllowancePerMonth(t *testing.T) { t.Fatalf("counters zeroed for a month the client can never use: up=%d down=%d", row.Up, row.Down) } } + +// Inclusive EOM 23:59:59 must not spend an allowance on the one-second snap +// to the billing midnight, or resetMax=1 leaves the client expired (#6300). +func TestAutoRenewClients_CalendarModeEndOfMonthExpiryDoesNotConsumeAllowance(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + zone := pinPanelZone(t, "UTC") + + now := time.Now().In(zone) + // Prior billing midnight B with B < now < B+1mo so one charged step lands ahead. + want := firstBillingMidnightAfter(t, now, 1, zone) + boundary := want.AddDate(0, -1, 0) + past := boundary.Add(-time.Second) + + clients := []model.Client{ + {Email: "eomcap@x", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Enable: false, ResetDay: 1, ResetMax: 1, ExpiryTime: past.UnixMilli()}, + } + ib := mkInbound(t, 30207, 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: "eomcap@x", Enable: false, ResetDay: 1, ResetMax: 1, ResetCount: 0, + Up: 7, Down: 8, 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 = ?", "eomcap@x").First(&row).Error; err != nil { + t.Fatal(err) + } + got := time.UnixMilli(row.ExpiryTime).In(zone) + if !got.Equal(want) { + t.Fatalf("renewed to %s, want %s (one full month after the billing midnight)", got.Format(time.RFC3339), want.Format(time.RFC3339)) + } + if row.ResetCount != 1 { + t.Fatalf("resetCount = %d, want 1: the EOM snap must not consume an allowance", row.ResetCount) + } + if !row.Enable { + t.Fatal("client remained expired: the single allowance was spent on the one-second alignment") + } + if row.Up != 0 || row.Down != 0 { + t.Fatalf("counters not reset: up=%d down=%d", row.Up, row.Down) + } +} + +// Unlimited EOM 23:59:59 must charge one renewal for the month, not two. +func TestAutoRenewClients_CalendarModeEndOfMonthUnlimitedChargesOnce(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + zone := pinPanelZone(t, "UTC") + + now := time.Now().In(zone) + want := firstBillingMidnightAfter(t, now, 1, zone) + boundary := want.AddDate(0, -1, 0) + past := boundary.Add(-time.Second) + + clients := []model.Client{ + {Email: "eomunc@x", ID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Enable: false, ResetDay: 1, ExpiryTime: past.UnixMilli()}, + } + ib := mkInbound(t, 30208, 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: "eomunc@x", Enable: false, ResetDay: 1, ResetCount: 0, + 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 = ?", "eomunc@x").First(&row).Error; err != nil { + t.Fatal(err) + } + got := time.UnixMilli(row.ExpiryTime).In(zone) + if !got.Equal(want) { + t.Fatalf("renewed to %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339)) + } + if row.ResetCount != 1 { + t.Fatalf("resetCount = %d, want 1: unlimited must not double-count the EOM alignment", row.ResetCount) + } +} + +// Mid-day expiry on the day before billing still charges for alignment; only +// the inclusive 23:59:59 instant is a free snap (#6300). +func TestAutoRenewClients_CalendarModeMidDayBeforeBillingChargesAlignment(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + zone := pinPanelZone(t, "UTC") + + now := time.Now().In(zone) + boundary := firstBillingMidnightAfter(t, now, 1, zone).AddDate(0, -1, 0) + past := boundary.Add(-14 * time.Hour) + + clients := []model.Client{ + {Email: "eommid@x", ID: "cccccccc-cccc-cccc-cccc-cccccccccccc", Enable: false, ResetDay: 1, ResetMax: 1, ExpiryTime: past.UnixMilli()}, + } + ib := mkInbound(t, 30209, 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: "eommid@x", Enable: false, ResetDay: 1, ResetMax: 1, ResetCount: 0, + 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 = ?", "eommid@x").First(&row).Error; err != nil { + t.Fatal(err) + } + got := time.UnixMilli(row.ExpiryTime).In(zone) + if !got.Equal(boundary) { + t.Fatalf("renewed to %s, want alignment onto %s (no free snap past midday)", got.Format(time.RFC3339), boundary.Format(time.RFC3339)) + } + if row.ResetCount != 1 { + t.Fatalf("resetCount = %d, want 1: midday alignment must consume the allowance", row.ResetCount) + } + if row.Enable { + t.Fatal("midday alignment exhausted resetMax=1; client must stay expired") + } +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 6b9e62cfd..64133c8ba 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -448,6 +448,15 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut continue } at := time.UnixMilli(newExpiryTime) + // Inclusive end-of-day expiries share the next billing midnight; snap without + // spending an allowance so the first charged step is a full month (#6300). + if traffic.ResetDay > 0 { + boundary := nextCalendarRenewal(at, traffic.ResetDay, renewLocation) + if !at.Before(boundary.Add(-time.Second)) && at.Before(boundary) { + at = boundary + newExpiryTime = at.UnixMilli() + } + } renewals := 0 for newExpiryTime < now { if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax {