diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go
index 9bfa27174db..b30407ecc77 100644
--- a/backend/cmd/server/wire.go
+++ b/backend/cmd/server/wire.go
@@ -98,6 +98,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
+ billingStatementEmail *service.BillingStatementEmailService,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -246,6 +247,12 @@ func provideCleanup(
}
return nil
}},
+ {"BillingStatementEmailService", func() error {
+ if billingStatementEmail != nil {
+ billingStatementEmail.Stop()
+ }
+ return nil
+ }},
}
infraSteps := []cleanupStep{
diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index a550118139e..0eb4a33ad95 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -260,13 +260,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig)
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService, settingRepository, opsService)
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
+ billingStatementEmailService := service.ProvideBillingStatementEmailService(settingRepository, userRepository, groupRepository, usageLogRepository, emailService, redisClient, configConfig)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
- v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner)
+ v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, billingStatementEmailService)
application := &Application{
Server: httpServer,
Cleanup: v,
@@ -321,6 +322,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
+ billingStatementEmail *service.BillingStatementEmailService,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -468,6 +470,12 @@ func provideCleanup(
}
return nil
}},
+ {"BillingStatementEmailService", func() error {
+ if billingStatementEmail != nil {
+ billingStatementEmail.Stop()
+ }
+ return nil
+ }},
}
infraSteps := []cleanupStep{
diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go
index 5ccd67fb5cd..395c5cdaf5e 100644
--- a/backend/cmd/server/wire_gen_test.go
+++ b/backend/cmd/server/wire_gen_test.go
@@ -77,6 +77,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
+ nil, // billingStatementEmail
)
require.NotPanics(t, func() {
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 7ad51660670..37cd1ac7795 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -263,6 +263,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
+
+ BillingStatementEmailConfig: settings.BillingStatementEmailConfig,
}
// OpenAI fast policy (stored under a dedicated setting key)
@@ -569,6 +571,9 @@ type UpdateSettingsRequest struct {
// 风控中心功能开关
RiskControlEnabled *bool `json:"risk_control_enabled"`
+ // Billing statement email config (JSON string, only updated when non-empty)
+ BillingStatementEmailConfig *string `json:"billing_statement_email_config,omitempty"`
+
// OpenAI fast/flex policy (optional, only updated when provided)
OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
}
@@ -1505,6 +1510,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.RiskControlEnabled
}(),
+ BillingStatementEmailConfig: func() string {
+ if req.BillingStatementEmailConfig != nil {
+ return *req.BillingStatementEmailConfig
+ }
+ return previousSettings.BillingStatementEmailConfig
+ }(),
}
authSourceDefaults := &service.AuthSourceDefaultSettings{
@@ -1783,9 +1794,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
- AffiliateEnabled: updatedSettings.AffiliateEnabled,
-
- RiskControlEnabled: updatedSettings.RiskControlEnabled,
+ AffiliateEnabled: updatedSettings.AffiliateEnabled,
+ RiskControlEnabled: updatedSettings.RiskControlEnabled,
+ BillingStatementEmailConfig: updatedSettings.BillingStatementEmailConfig,
}
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
slog.Error("openai_fast_policy_settings_get_failed", "error", err)
diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go
index 2559b112cb9..870d6dde37c 100644
--- a/backend/internal/handler/dto/mappers.go
+++ b/backend/internal/handler/dto/mappers.go
@@ -13,23 +13,27 @@ func UserFromServiceShallow(u *service.User) *User {
return nil
}
return &User{
- ID: u.ID,
- Email: u.Email,
- Username: u.Username,
- Role: u.Role,
- Balance: u.Balance,
- Concurrency: u.Concurrency,
- Status: u.Status,
- AllowedGroups: u.AllowedGroups,
- LastActiveAt: u.LastActiveAt,
- CreatedAt: u.CreatedAt,
- UpdatedAt: u.UpdatedAt,
- BalanceNotifyEnabled: u.BalanceNotifyEnabled,
- BalanceNotifyThresholdType: u.BalanceNotifyThresholdType,
- BalanceNotifyThreshold: u.BalanceNotifyThreshold,
- BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails),
- TotalRecharged: u.TotalRecharged,
- RPMLimit: u.RPMLimit,
+ ID: u.ID,
+ Email: u.Email,
+ Username: u.Username,
+ Role: u.Role,
+ Balance: u.Balance,
+ Concurrency: u.Concurrency,
+ Status: u.Status,
+ AllowedGroups: u.AllowedGroups,
+ LastActiveAt: u.LastActiveAt,
+ CreatedAt: u.CreatedAt,
+ UpdatedAt: u.UpdatedAt,
+ BalanceNotifyEnabled: u.BalanceNotifyEnabled,
+ BalanceNotifyThresholdType: u.BalanceNotifyThresholdType,
+ BalanceNotifyThreshold: u.BalanceNotifyThreshold,
+ BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails),
+ TotalRecharged: u.TotalRecharged,
+ RPMLimit: u.RPMLimit,
+ BillingStatementDailyEnabled: u.BillingStatementDailyEnabled,
+ BillingStatementWeeklyEnabled: u.BillingStatementWeeklyEnabled,
+ BillingStatementMonthlyEnabled: u.BillingStatementMonthlyEnabled,
+ Timezone: u.Timezone,
}
}
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 2d4cefa1595..2a4d11295ae 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -219,6 +219,9 @@ type SystemSettings struct {
// Affiliate (邀请返利) feature switch
AffiliateEnabled bool `json:"affiliate_enabled"`
+ // Billing statement email config (JSON string)
+ BillingStatementEmailConfig string `json:"billing_statement_email_config"`
+
// OpenAI fast/flex policy
OpenAIFastPolicySettings *OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
}
@@ -283,7 +286,12 @@ type PublicSettings struct {
AffiliateEnabled bool `json:"affiliate_enabled"`
- RiskControlEnabled bool `json:"risk_control_enabled"`
+ RiskControlEnabled bool `json:"risk_control_enabled"`
+ BillingStatementEmailEnabled bool `json:"billing_statement_email_enabled"`
+ BillingStatementDailyEnabled bool `json:"billing_statement_daily_enabled"`
+ BillingStatementWeeklyEnabled bool `json:"billing_statement_weekly_enabled"`
+ BillingStatementMonthlyEnabled bool `json:"billing_statement_monthly_enabled"`
+ ServerTimezone string `json:"server_timezone"`
}
type LoginAgreementDocument struct {
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index e15a916eec4..60042bbd308 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -27,7 +27,13 @@ type User struct {
TotalRecharged float64 `json:"total_recharged"`
// RPMLimit 用户级每分钟请求数上限(0 = 不限制),仅在所用分组未设置 rpm_limit 时作为兜底生效。
- RPMLimit int `json:"rpm_limit"`
+ RPMLimit int `json:"rpm_limit"`
+ Timezone string `json:"timezone"`
+
+ // 账单邮件偏好
+ BillingStatementDailyEnabled bool `json:"billing_statement_daily_enabled"`
+ BillingStatementWeeklyEnabled bool `json:"billing_statement_weekly_enabled"`
+ BillingStatementMonthlyEnabled bool `json:"billing_statement_monthly_enabled"`
APIKeys []APIKey `json:"api_keys,omitempty"`
Subscriptions []UserSubscription `json:"subscriptions,omitempty"`
diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go
index 6c389e3da1e..5937c0ded62 100644
--- a/backend/internal/handler/setting_handler.go
+++ b/backend/internal/handler/setting_handler.go
@@ -85,7 +85,12 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
AffiliateEnabled: settings.AffiliateEnabled,
- RiskControlEnabled: settings.RiskControlEnabled,
+ RiskControlEnabled: settings.RiskControlEnabled,
+ BillingStatementEmailEnabled: settings.BillingStatementEmailEnabled,
+ BillingStatementDailyEnabled: settings.BillingStatementDailyEnabled,
+ BillingStatementWeeklyEnabled: settings.BillingStatementWeeklyEnabled,
+ BillingStatementMonthlyEnabled: settings.BillingStatementMonthlyEnabled,
+ ServerTimezone: settings.ServerTimezone,
})
}
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index 3f6ed8c2bcc..2306c85ef2a 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -50,6 +50,12 @@ type UpdateProfileRequest struct {
AvatarURL *string `json:"avatar_url"`
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
+
+ // Billing statement email preferences
+ BillingStatementDailyEnabled *bool `json:"billing_statement_daily_enabled"`
+ BillingStatementWeeklyEnabled *bool `json:"billing_statement_weekly_enabled"`
+ BillingStatementMonthlyEnabled *bool `json:"billing_statement_monthly_enabled"`
+ Timezone *string `json:"timezone"`
}
type userProfileResponse struct {
@@ -142,10 +148,14 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
}
svcReq := service.UpdateProfileRequest{
- Username: req.Username,
- AvatarURL: req.AvatarURL,
- BalanceNotifyEnabled: req.BalanceNotifyEnabled,
- BalanceNotifyThreshold: req.BalanceNotifyThreshold,
+ Username: req.Username,
+ AvatarURL: req.AvatarURL,
+ BalanceNotifyEnabled: req.BalanceNotifyEnabled,
+ BalanceNotifyThreshold: req.BalanceNotifyThreshold,
+ BillingStatementDailyEnabled: req.BillingStatementDailyEnabled,
+ BillingStatementWeeklyEnabled: req.BillingStatementWeeklyEnabled,
+ BillingStatementMonthlyEnabled: req.BillingStatementMonthlyEnabled,
+ Timezone: req.Timezone,
}
updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq)
if err != nil {
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 27358865666..420432c562b 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -65,6 +65,10 @@ func TestAPIContracts(t *testing.T) {
"balance_notify_threshold": null,
"balance_notify_extra_emails": null,
"total_recharged": 0,
+ "timezone": "UTC",
+ "billing_statement_daily_enabled": false,
+ "billing_statement_weekly_enabled": false,
+ "billing_statement_monthly_enabled": false,
"linuxdo_bound": false,
"oidc_bound": false,
"wechat_bound": false,
@@ -818,6 +822,7 @@ func TestAPIContracts(t *testing.T) {
"balance_low_notify_threshold": 0,
"balance_low_notify_recharge_url": "",
"account_quota_notify_emails": [],
+ "billing_statement_email_config": "",
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
@@ -1029,6 +1034,7 @@ func TestAPIContracts(t *testing.T) {
"balance_low_notify_threshold": 0,
"balance_low_notify_recharge_url": "",
"account_quota_notify_emails": [],
+ "billing_statement_email_config": "",
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
diff --git a/backend/internal/service/billing_statement_email_service.go b/backend/internal/service/billing_statement_email_service.go
new file mode 100644
index 00000000000..b090f1007c8
--- /dev/null
+++ b/backend/internal/service/billing_statement_email_service.go
@@ -0,0 +1,834 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/google/uuid"
+ "github.com/robfig/cron/v3"
+)
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Configuration model (stored as JSON in setting key billing_statement_email_config)
+// ─────────────────────────────────────────────────────────────────────────────
+
+// BillingStatementEmailConfig is the JSON structure stored in the setting key.
+type BillingStatementEmailConfig struct {
+ Enabled bool `json:"enabled"`
+ DailyEnabled bool `json:"daily_enabled"`
+ WeeklyEnabled bool `json:"weekly_enabled"`
+ MonthlyEnabled bool `json:"monthly_enabled"`
+ DailySchedule string `json:"daily_schedule"` // cron spec (5-field)
+ WeeklySchedule string `json:"weekly_schedule"` // cron spec (5-field)
+ MonthlySchedule string `json:"monthly_schedule"` // cron spec (5-field)
+}
+
+// DefaultBillingStatementEmailConfig returns the default configuration.
+func DefaultBillingStatementEmailConfig() BillingStatementEmailConfig {
+ return BillingStatementEmailConfig{
+ Enabled: false,
+ DailyEnabled: false,
+ WeeklyEnabled: false,
+ MonthlyEnabled: false,
+ DailySchedule: "0 8 * * *", // every day at 08:00
+ WeeklySchedule: "0 8 * * 1", // every Monday at 08:00
+ MonthlySchedule: "0 8 1 * *", // 1st of month at 08:00
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// User preference model (stored per-user in setting key billing_statement_user_pref:{userID})
+// ─────────────────────────────────────────────────────────────────────────────
+
+// BillingStatementUserPreference represents a user's opt-in/out for each billing period.
+type BillingStatementUserPreference struct {
+ DailyEnabled bool `json:"daily_enabled"`
+ WeeklyEnabled bool `json:"weekly_enabled"`
+ MonthlyEnabled bool `json:"monthly_enabled"`
+}
+
+// DefaultBillingStatementUserPreference returns the default preference (all disabled).
+// Existing users without an explicit preference JSON should not receive billing
+// statements until they opt in from their profile.
+func DefaultBillingStatementUserPreference() BillingStatementUserPreference {
+ return BillingStatementUserPreference{
+ DailyEnabled: false,
+ WeeklyEnabled: false,
+ MonthlyEnabled: false,
+ }
+}
+
+// ParseBillingStatementUserPreference parses JSON into preference, falling back to defaults.
+func ParseBillingStatementUserPreference(raw string) BillingStatementUserPreference {
+ pref := DefaultBillingStatementUserPreference()
+ if strings.TrimSpace(raw) == "" {
+ return pref
+ }
+ if err := json.Unmarshal([]byte(raw), &pref); err != nil {
+ return DefaultBillingStatementUserPreference()
+ }
+ return pref
+}
+
+// billingStatementUserPreferenceSettingKey returns the setting key for a user's billing preference.
+func billingStatementUserPreferenceSettingKey(userID int64) string {
+ return SettingKeyBillingStatementUserPreferencePrefix + strconv.FormatInt(userID, 10)
+}
+
+// ParseBillingStatementEmailConfig parses JSON into config, falling back to defaults.
+func ParseBillingStatementEmailConfig(raw string) BillingStatementEmailConfig {
+ cfg := DefaultBillingStatementEmailConfig()
+ if strings.TrimSpace(raw) == "" {
+ return cfg
+ }
+ if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+ return DefaultBillingStatementEmailConfig()
+ }
+ return cfg
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Aggregation DTO (computed from usage_logs)
+// ─────────────────────────────────────────────────────────────────────────────
+
+// BillingStatementLine represents one aggregated line in the billing statement.
+type BillingStatementLine struct {
+ Model string `json:"model"`
+ BillingMode string `json:"billing_mode"` // "token" / "image" / ""
+ GroupID *int64 `json:"group_id"`
+ GroupName string `json:"group_name"`
+ Subscription *int64 `json:"subscription_id"`
+ Requests int64 `json:"requests"`
+ TotalTokens int64 `json:"total_tokens"`
+ TotalCost float64 `json:"total_cost"` // standard price
+ ActualCost float64 `json:"actual_cost"` // user price after multiplier
+ Discount float64 `json:"discount"` // total_cost - actual_cost
+}
+
+// BillingStatement is the full statement for one user in a time range.
+type BillingStatement struct {
+ UserID int64
+ UserEmail string
+ PeriodName string // "日账单" / "周账单" / "月账单"
+ Start time.Time
+ End time.Time
+ Timezone string
+ Lines []BillingStatementLine
+ TotalCost float64
+ ActualCost float64
+ Discount float64
+ Balance float64
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Service
+// ─────────────────────────────────────────────────────────────────────────────
+
+const (
+ billingStatementLeaderLockKey = "billing_statement:leader"
+ billingStatementLeaderLockTTL = 5 * time.Minute
+
+ billingStatementLastRunKeyPrefix = "billing_statement:last_run:"
+ billingStatementTickInterval = 1 * time.Minute
+
+ billingStatementUserPageSize = 100
+)
+
+var billingStatementCronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
+
+type billingStatementRedisClient interface {
+ SetNX(ctx context.Context, key string, value any, expiration time.Duration) (bool, error)
+ Get(ctx context.Context, key string) (string, error)
+ Set(ctx context.Context, key string, value any, expiration time.Duration) error
+ ReleaseIfValue(ctx context.Context, key string, value string) error
+}
+
+// BillingStatementEmailService sends periodic billing statement emails to users.
+type BillingStatementEmailService struct {
+ settingRepo SettingRepository
+ userRepo UserRepository
+ groupRepo GroupRepository
+ usageRepo UsageLogRepository
+ emailService *EmailService
+ redisClient billingStatementRedisClient
+ cfg *config.Config
+
+ instanceID string
+ loc *time.Location
+
+ distributedLockOn bool
+ warnNoRedisOnce sync.Once
+
+ startOnce sync.Once
+ stopOnce sync.Once
+ stopCtx context.Context
+ stop context.CancelFunc
+ wg sync.WaitGroup
+}
+
+// NewBillingStatementEmailService creates the service.
+func NewBillingStatementEmailService(
+ settingRepo SettingRepository,
+ userRepo UserRepository,
+ groupRepo GroupRepository,
+ usageRepo UsageLogRepository,
+ emailService *EmailService,
+ redisClient billingStatementRedisClient,
+ cfg *config.Config,
+) *BillingStatementEmailService {
+ lockOn := cfg == nil || strings.TrimSpace(cfg.RunMode) != config.RunModeSimple
+
+ loc := time.Local
+ if cfg != nil && strings.TrimSpace(cfg.Timezone) != "" {
+ if parsed, err := time.LoadLocation(strings.TrimSpace(cfg.Timezone)); err == nil && parsed != nil {
+ loc = parsed
+ }
+ }
+ return &BillingStatementEmailService{
+ settingRepo: settingRepo,
+ userRepo: userRepo,
+ groupRepo: groupRepo,
+ usageRepo: usageRepo,
+ emailService: emailService,
+ redisClient: redisClient,
+ cfg: cfg,
+ instanceID: uuid.NewString(),
+ loc: loc,
+ distributedLockOn: lockOn,
+ }
+}
+
+// Start begins the background ticker.
+func (s *BillingStatementEmailService) Start() {
+ s.StartWithContext(context.Background())
+}
+
+// StartWithContext begins the background ticker with a parent context.
+func (s *BillingStatementEmailService) StartWithContext(ctx context.Context) {
+ if s == nil {
+ return
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if s.emailService == nil || s.settingRepo == nil {
+ return
+ }
+
+ s.startOnce.Do(func() {
+ s.stopCtx, s.stop = context.WithCancel(ctx)
+ s.wg.Add(1)
+ go s.run()
+ })
+}
+
+// Stop gracefully stops the service.
+func (s *BillingStatementEmailService) Stop() {
+ if s == nil {
+ return
+ }
+ s.stopOnce.Do(func() {
+ if s.stop != nil {
+ s.stop()
+ }
+ })
+ s.wg.Wait()
+}
+
+func (s *BillingStatementEmailService) run() {
+ defer s.wg.Done()
+
+ ticker := time.NewTicker(billingStatementTickInterval)
+ defer ticker.Stop()
+
+ s.runOnce()
+ for {
+ select {
+ case <-ticker.C:
+ s.runOnce()
+ case <-s.stopCtx.Done():
+ return
+ }
+ }
+}
+
+func (s *BillingStatementEmailService) runOnce() {
+ if s == nil || s.emailService == nil || s.settingRepo == nil {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(s.stopCtx, 120*time.Second)
+ defer cancel()
+
+ // Read config from settings
+ billingCfg := s.loadConfig(ctx)
+ if !billingCfg.Enabled {
+ return
+ }
+
+ // Acquire leader lock
+ release, ok := s.tryAcquireLeaderLock(ctx)
+ if !ok {
+ return
+ }
+ if release != nil {
+ defer release()
+ }
+
+ now := time.Now()
+ if s.loc != nil {
+ now = now.In(s.loc)
+ }
+
+ type statementDef struct {
+ enabled bool
+ kind string
+ name string
+ schedule string
+ }
+
+ defs := []statementDef{
+ {enabled: billingCfg.DailyEnabled, kind: "daily", name: "日账单 / Daily Billing Statement", schedule: billingCfg.DailySchedule},
+ {enabled: billingCfg.WeeklyEnabled, kind: "weekly", name: "周账单 / Weekly Billing Statement", schedule: billingCfg.WeeklySchedule},
+ {enabled: billingCfg.MonthlyEnabled, kind: "monthly", name: "月账单 / Monthly Billing Statement", schedule: billingCfg.MonthlySchedule},
+ }
+
+ for _, d := range defs {
+ if !d.enabled {
+ continue
+ }
+ spec := strings.TrimSpace(d.schedule)
+ if spec == "" {
+ continue
+ }
+ sched, err := billingStatementCronParser.Parse(spec)
+ if err != nil {
+ log.Printf("[BillingStatement] invalid cron spec=%q for kind=%s: %v", spec, d.kind, err)
+ continue
+ }
+
+ lastRun := s.getLastRunAt(ctx, d.kind)
+ base := lastRun
+ if base.IsZero() {
+ base = now.Add(-1 * time.Minute)
+ }
+ next := sched.Next(base)
+ if next.IsZero() || next.After(now) {
+ continue
+ }
+ if !s.isEmailDeliveryConfigured(ctx) {
+ continue
+ }
+
+ // Time to run this statement. Only record last_run after the
+ // send cycle completes without the context being canceled/timing out,
+ // so interrupted runs remain eligible for retry on the next pass.
+ s.sendStatements(ctx, d.kind, d.name, now)
+ if ctx.Err() != nil {
+ log.Printf("[BillingStatement] send interrupted for kind=%s; last_run not updated: %v", d.kind, ctx.Err())
+ continue
+ }
+ s.setLastRunAt(ctx, d.kind, now)
+ }
+}
+
+func (s *BillingStatementEmailService) isEmailDeliveryConfigured(ctx context.Context) bool {
+ if s == nil || s.emailService == nil {
+ return false
+ }
+ if _, err := s.emailService.GetSMTPConfig(ctx); err != nil {
+ log.Printf("[BillingStatement] email delivery is not configured; skipping: %v", err)
+ return false
+ }
+ return true
+}
+
+func (s *BillingStatementEmailService) loadConfig(ctx context.Context) BillingStatementEmailConfig {
+ raw, err := s.settingRepo.GetValue(ctx, SettingKeyBillingStatementEmailConfig)
+ if err != nil || strings.TrimSpace(raw) == "" {
+ return DefaultBillingStatementEmailConfig()
+ }
+ return ParseBillingStatementEmailConfig(raw)
+}
+
+func (s *BillingStatementEmailService) sendStatements(ctx context.Context, kind string, periodName string, now time.Time) {
+ page := 1
+ for {
+ users, pageResult, err := s.userRepo.List(ctx, pagination.PaginationParams{
+ Page: page,
+ PageSize: billingStatementUserPageSize,
+ })
+ if err != nil {
+ log.Printf("[BillingStatement] list users page=%d error: %v", page, err)
+ return
+ }
+
+ for i := range users {
+ user := &users[i]
+ if !isValidEmailForBilling(user.Email) {
+ continue
+ }
+ // Check user preference for this period kind
+ if !s.isUserPeriodEnabled(ctx, user.ID, kind) {
+ continue
+ }
+ loc := s.userLocation(ctx, user.ID)
+ start, end := billingStatementPeriodRange(kind, now, loc)
+ s.sendStatementToUser(ctx, user, periodName, start, end, loc)
+ }
+
+ if pageResult == nil || page >= pageResult.Pages {
+ break
+ }
+ page++
+ }
+}
+
+func (s *BillingStatementEmailService) userLocation(ctx context.Context, userID int64) *time.Location {
+ if s == nil {
+ return time.Local
+ }
+ if s.settingRepo != nil {
+ if raw, err := s.settingRepo.GetValue(ctx, userTimezoneSettingKey(userID)); err == nil {
+ if loc, err := time.LoadLocation(strings.TrimSpace(raw)); err == nil && loc != nil {
+ return loc
+ }
+ }
+ }
+ if s.loc != nil {
+ return s.loc
+ }
+ return time.Local
+}
+
+func billingStatementPeriodRange(kind string, now time.Time, loc *time.Location) (time.Time, time.Time) {
+ if loc == nil {
+ loc = time.Local
+ }
+ localNow := now.In(loc)
+ switch kind {
+ case "weekly":
+ end := startOfBillingStatementWeek(localNow, loc)
+ return end.AddDate(0, 0, -7), end
+ case "monthly":
+ end := time.Date(localNow.Year(), localNow.Month(), 1, 0, 0, 0, 0, loc)
+ return end.AddDate(0, -1, 0), end
+ default:
+ end := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
+ return end.AddDate(0, 0, -1), end
+ }
+}
+
+func startOfBillingStatementWeek(t time.Time, loc *time.Location) time.Time {
+ if loc == nil {
+ loc = time.Local
+ }
+ t = t.In(loc)
+ weekday := int(t.Weekday())
+ if weekday == 0 {
+ weekday = 7
+ }
+ return time.Date(t.Year(), t.Month(), t.Day()-weekday+1, 0, 0, 0, 0, loc)
+}
+
+// isUserPeriodEnabled checks whether the user has opted in for the given period kind.
+func (s *BillingStatementEmailService) isUserPeriodEnabled(ctx context.Context, userID int64, kind string) bool {
+ raw, err := s.settingRepo.GetValue(ctx, billingStatementUserPreferenceSettingKey(userID))
+ if err != nil || strings.TrimSpace(raw) == "" {
+ // Default: no explicit user opt-in, do not send.
+ return false
+ }
+ pref := ParseBillingStatementUserPreference(raw)
+ switch kind {
+ case "daily":
+ return pref.DailyEnabled
+ case "weekly":
+ return pref.WeeklyEnabled
+ case "monthly":
+ return pref.MonthlyEnabled
+ default:
+ return true
+ }
+}
+
+func (s *BillingStatementEmailService) sendStatementToUser(ctx context.Context, user *User, periodName string, start, end time.Time, loc *time.Location) {
+ // Aggregate usage for this user in the time range
+ lines := s.aggregateUserUsage(ctx, user.ID, start, end)
+ if len(lines) == 0 {
+ // No usage in this period, skip sending
+ return
+ }
+
+ var totalCost, actualCost, discount float64
+ for _, l := range lines {
+ totalCost += l.TotalCost
+ actualCost += l.ActualCost
+ discount += l.Discount
+ }
+
+ stmt := &BillingStatement{
+ UserID: user.ID,
+ UserEmail: user.Email,
+ PeriodName: periodName,
+ Start: start,
+ End: end,
+ Timezone: billingStatementLocationName(loc),
+ Lines: lines,
+ TotalCost: totalCost,
+ ActualCost: actualCost,
+ Discount: discount,
+ Balance: user.Balance,
+ }
+
+ subject := fmt.Sprintf("[%s] %s (%s ~ %s)",
+ "Sub2API",
+ periodName,
+ start.In(loc).Format("2006-01-02"),
+ end.In(loc).Format("2006-01-02"),
+ )
+
+ // Try to get site name
+ if siteName, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && strings.TrimSpace(siteName) != "" {
+ subject = fmt.Sprintf("[%s] %s (%s ~ %s)",
+ strings.TrimSpace(siteName),
+ periodName,
+ start.In(loc).Format("2006-01-02"),
+ end.In(loc).Format("2006-01-02"),
+ )
+ }
+
+ body := buildBillingStatementEmailHTML(stmt)
+ if err := s.emailService.SendEmail(ctx, user.Email, subject, body); err != nil {
+ log.Printf("[BillingStatement] send email to %s failed: %v", user.Email, err)
+ }
+}
+
+func billingStatementLocationName(loc *time.Location) string {
+ if loc == nil {
+ return "UTC"
+ }
+ name := strings.TrimSpace(loc.String())
+ if name == "" || name == "Local" {
+ return "UTC"
+ }
+ return name
+}
+
+func (s *BillingStatementEmailService) aggregateUserUsage(ctx context.Context, userID int64, start, end time.Time) []BillingStatementLine {
+ // Use ListByUserAndTimeRange to get raw logs, then aggregate in-memory.
+ // This is the minimal viable approach using existing repository methods.
+ logs, _, err := s.usageRepo.ListByUserAndTimeRange(ctx, userID, start, end)
+ if err != nil {
+ log.Printf("[BillingStatement] query usage for user=%d error: %v", userID, err)
+ return nil
+ }
+ if len(logs) == 0 {
+ return nil
+ }
+
+ type aggKey struct {
+ Model string
+ BillingMode string
+ GroupID int64 // 0 = nil
+ Subscription int64 // 0 = nil
+ }
+
+ agg := make(map[aggKey]*BillingStatementLine)
+ for i := range logs {
+ l := &logs[i]
+ bm := ""
+ if l.BillingMode != nil {
+ bm = *l.BillingMode
+ }
+ var gid int64
+ if l.GroupID != nil {
+ gid = *l.GroupID
+ }
+ var sid int64
+ if l.SubscriptionID != nil {
+ sid = *l.SubscriptionID
+ }
+ key := aggKey{
+ Model: l.Model,
+ BillingMode: bm,
+ GroupID: gid,
+ Subscription: sid,
+ }
+ entry, ok := agg[key]
+ if !ok {
+ entry = &BillingStatementLine{
+ Model: l.Model,
+ BillingMode: bm,
+ }
+ if gid != 0 {
+ g := gid
+ entry.GroupID = &g
+ }
+ if sid != 0 {
+ ss := sid
+ entry.Subscription = &ss
+ }
+ agg[key] = entry
+ }
+ entry.Requests++
+ entry.TotalTokens += int64(l.TotalTokens())
+ entry.TotalCost += l.TotalCost
+ entry.ActualCost += l.ActualCost
+ entry.Discount += l.TotalCost - l.ActualCost
+ }
+
+ result := make([]BillingStatementLine, 0, len(agg))
+ for _, v := range agg {
+ result = append(result, *v)
+ }
+ s.hydrateBillingStatementGroupNames(ctx, result)
+ sort.SliceStable(result, func(i, j int) bool {
+ if result[i].Model != result[j].Model {
+ return result[i].Model < result[j].Model
+ }
+ if result[i].GroupName != result[j].GroupName {
+ return result[i].GroupName < result[j].GroupName
+ }
+ return result[i].BillingMode < result[j].BillingMode
+ })
+ return result
+}
+
+func (s *BillingStatementEmailService) hydrateBillingStatementGroupNames(ctx context.Context, lines []BillingStatementLine) {
+ if s == nil || s.groupRepo == nil || len(lines) == 0 {
+ return
+ }
+ cache := make(map[int64]string)
+ for i := range lines {
+ if lines[i].GroupID == nil {
+ continue
+ }
+ groupID := *lines[i].GroupID
+ if name, ok := cache[groupID]; ok {
+ lines[i].GroupName = name
+ continue
+ }
+ group, err := s.groupRepo.GetByIDLite(ctx, groupID)
+ if err != nil || group == nil || strings.TrimSpace(group.Name) == "" {
+ cache[groupID] = fmt.Sprintf("#%d", groupID)
+ } else {
+ cache[groupID] = strings.TrimSpace(group.Name)
+ }
+ lines[i].GroupName = cache[groupID]
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Email HTML template
+// ─────────────────────────────────────────────────────────────────────────────
+
+func buildBillingStatementEmailHTML(stmt *BillingStatement) string {
+ if stmt == nil {
+ return "
无数据 / No data.
"
+ }
+
+ var rows strings.Builder
+ for _, line := range stmt.Lines {
+ billingMode := billingStatementBillingModeLabel(line.BillingMode)
+ groupStr := "-"
+ if strings.TrimSpace(line.GroupName) != "" {
+ groupStr = strings.TrimSpace(line.GroupName)
+ } else if line.GroupID != nil {
+ groupStr = fmt.Sprintf("#%d", *line.GroupID)
+ }
+ subStr := "-"
+ if line.Subscription != nil {
+ subStr = fmt.Sprintf("#%d", *line.Subscription)
+ }
+ _, _ = rows.WriteString(fmt.Sprintf(
+ "| %s | %s | %s | %s | %d | %d | $%.4f | $%.4f | $%.4f |
",
+ billingStatementHTMLEscape(line.Model),
+ billingStatementHTMLEscape(billingMode),
+ billingStatementHTMLEscape(groupStr),
+ billingStatementHTMLEscape(subStr),
+ line.Requests,
+ line.TotalTokens,
+ line.TotalCost,
+ line.ActualCost,
+ line.Discount,
+ ))
+ }
+
+ return fmt.Sprintf(`
+
+
+
+
+
+
+
+
+
+
致 %s:
To %s:
+
时间段 / Period: %s ~ %s (%s)
+
+| 模型 / Model | 计费模式 / Billing Mode | 分组 / Group | 订阅 / Subscription | 请求数 / Requests | Token 数 / Tokens | 标准价格 / Standard Price | 实际价格 / Actual Price | 优惠差额 / Discount |
+%s
+
+
+
标准总价 / Total Cost: $%.4f
+
实际总价 / Actual Cost: $%.4f
+
优惠总额 / Total Discount: $%.4f
+
账户余额 / Balance: $%.4f
+
+
+
+
+
+`,
+ billingStatementBilingualTitleHTML(stmt.PeriodName),
+ billingStatementHTMLEscape(stmt.UserEmail),
+ billingStatementHTMLEscape(stmt.UserEmail),
+ billingStatementHTMLEscape(stmt.Start.Format("2006-01-02 15:04")),
+ billingStatementHTMLEscape(stmt.End.Format("2006-01-02 15:04")),
+ billingStatementHTMLEscape(stmt.Timezone),
+ rows.String(),
+ stmt.TotalCost,
+ stmt.ActualCost,
+ stmt.Discount,
+ stmt.Balance,
+ )
+}
+
+func billingStatementBillingModeLabel(mode string) string {
+ switch strings.TrimSpace(strings.ToLower(mode)) {
+ case "image":
+ return "图片 / Image"
+ case "token", "":
+ return "Token"
+ default:
+ return mode
+ }
+}
+
+func billingStatementBilingualTitleHTML(title string) string {
+ parts := strings.SplitN(strings.TrimSpace(title), " / ", 2)
+ if len(parts) != 2 {
+ return billingStatementHTMLEscape(title)
+ }
+ return fmt.Sprintf("%s%s",
+ billingStatementHTMLEscape(parts[0]),
+ billingStatementHTMLEscape(parts[1]),
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+// isValidEmailForBilling returns true if the email is a real deliverable address.
+func isValidEmailForBilling(email string) bool {
+ addr := strings.TrimSpace(email)
+ if addr == "" {
+ return false
+ }
+ if !strings.Contains(addr, "@") {
+ return false
+ }
+ // Skip synthetic/invalid emails from third-party auth
+ if strings.HasSuffix(addr, ".invalid") {
+ return false
+ }
+ return true
+}
+
+func billingStatementHTMLEscape(s string) string {
+ replacer := strings.NewReplacer(
+ "&", "&",
+ "<", "<",
+ ">", ">",
+ `"`, """,
+ )
+ return replacer.Replace(s)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Distributed lock + last_run (same pattern as ops_scheduled_report_service)
+// ─────────────────────────────────────────────────────────────────────────────
+
+func (s *BillingStatementEmailService) tryAcquireLeaderLock(ctx context.Context) (func(), bool) {
+ if s == nil || !s.distributedLockOn {
+ return nil, true
+ }
+ if s.redisClient == nil {
+ s.warnNoRedisOnce.Do(func() {
+ log.Printf("[BillingStatement] redis not configured; running without distributed lock")
+ })
+ return nil, true
+ }
+
+ ok, err := s.redisClient.SetNX(ctx, billingStatementLeaderLockKey, s.instanceID, billingStatementLeaderLockTTL)
+ if err != nil {
+ log.Printf("[BillingStatement] leader lock SetNX failed; skipping: %v", err)
+ return nil, false
+ }
+ if !ok {
+ return nil, false
+ }
+ return func() {
+ _ = s.redisClient.ReleaseIfValue(ctx, billingStatementLeaderLockKey, s.instanceID)
+ }, true
+}
+
+func (s *BillingStatementEmailService) getLastRunAt(ctx context.Context, kind string) time.Time {
+ if s == nil || s.redisClient == nil {
+ return time.Time{}
+ }
+ key := billingStatementLastRunKeyPrefix + strings.TrimSpace(kind)
+ raw, err := s.redisClient.Get(ctx, key)
+ if err != nil || strings.TrimSpace(raw) == "" {
+ return time.Time{}
+ }
+ sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+ if err != nil || sec <= 0 {
+ return time.Time{}
+ }
+ last := time.Unix(sec, 0)
+ if s.loc != nil {
+ return last.In(s.loc)
+ }
+ return last.UTC()
+}
+
+func (s *BillingStatementEmailService) setLastRunAt(ctx context.Context, kind string, t time.Time) {
+ if s == nil || s.redisClient == nil {
+ return
+ }
+ key := billingStatementLastRunKeyPrefix + strings.TrimSpace(kind)
+ if t.IsZero() {
+ t = time.Now().UTC()
+ }
+ _ = s.redisClient.Set(ctx, key, strconv.FormatInt(t.UTC().Unix(), 10), 35*24*time.Hour)
+}
diff --git a/backend/internal/service/billing_statement_email_service_test.go b/backend/internal/service/billing_statement_email_service_test.go
new file mode 100644
index 00000000000..63b070ffc58
--- /dev/null
+++ b/backend/internal/service/billing_statement_email_service_test.go
@@ -0,0 +1,133 @@
+package service
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseBillingStatementEmailConfig_Empty(t *testing.T) {
+ cfg := ParseBillingStatementEmailConfig("")
+ def := DefaultBillingStatementEmailConfig()
+ if cfg.Enabled != def.Enabled {
+ t.Errorf("expected Enabled=%v, got %v", def.Enabled, cfg.Enabled)
+ }
+ if cfg.DailySchedule != def.DailySchedule {
+ t.Errorf("expected DailySchedule=%q, got %q", def.DailySchedule, cfg.DailySchedule)
+ }
+}
+
+func TestParseBillingStatementEmailConfig_Valid(t *testing.T) {
+ raw := `{"enabled":true,"daily_enabled":true,"weekly_enabled":false,"monthly_enabled":true,"daily_schedule":"30 9 * * *","weekly_schedule":"0 8 * * 1","monthly_schedule":"0 8 1 * *"}`
+ cfg := ParseBillingStatementEmailConfig(raw)
+ if !cfg.Enabled {
+ t.Error("expected Enabled=true")
+ }
+ if !cfg.DailyEnabled {
+ t.Error("expected DailyEnabled=true")
+ }
+ if cfg.WeeklyEnabled {
+ t.Error("expected WeeklyEnabled=false")
+ }
+ if !cfg.MonthlyEnabled {
+ t.Error("expected MonthlyEnabled=true")
+ }
+ if cfg.DailySchedule != "30 9 * * *" {
+ t.Errorf("expected DailySchedule='30 9 * * *', got %q", cfg.DailySchedule)
+ }
+}
+
+func TestParseBillingStatementEmailConfig_Invalid(t *testing.T) {
+ cfg := ParseBillingStatementEmailConfig("{invalid json")
+ def := DefaultBillingStatementEmailConfig()
+ if cfg.Enabled != def.Enabled {
+ t.Errorf("expected fallback to default on invalid JSON")
+ }
+}
+
+func TestIsValidEmailForBilling(t *testing.T) {
+ tests := []struct {
+ email string
+ want bool
+ }{
+ {"user@example.com", true},
+ {"", false},
+ {"noatsign", false},
+ {"user@linuxdo-connect.invalid", false},
+ {"user@oidc-connect.invalid", false},
+ {"user@wechat-connect.invalid", false},
+ {"admin@company.org", true},
+ }
+ for _, tt := range tests {
+ got := isValidEmailForBilling(tt.email)
+ if got != tt.want {
+ t.Errorf("isValidEmailForBilling(%q) = %v, want %v", tt.email, got, tt.want)
+ }
+ }
+}
+
+func TestBuildBillingStatementEmailHTML_Nil(t *testing.T) {
+ html := buildBillingStatementEmailHTML(nil)
+ if html != "无数据 / No data.
" {
+ t.Errorf("expected no-data HTML for nil statement")
+ }
+}
+
+func TestBuildBillingStatementEmailHTML_Basic(t *testing.T) {
+ gid := int64(1)
+ stmt := &BillingStatement{
+ UserID: 1,
+ UserEmail: "test@example.com",
+ PeriodName: "日账单",
+ Start: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
+ Lines: []BillingStatementLine{
+ {
+ Model: "claude-sonnet-4-20250514",
+ BillingMode: "token",
+ GroupID: &gid,
+ Requests: 10,
+ TotalTokens: 50000,
+ TotalCost: 1.5,
+ ActualCost: 1.2,
+ Discount: 0.3,
+ },
+ },
+ TotalCost: 1.5,
+ ActualCost: 1.2,
+ Discount: 0.3,
+ Balance: 8.5,
+ }
+ html := buildBillingStatementEmailHTML(stmt)
+ if html == "" {
+ t.Error("expected non-empty HTML")
+ }
+ // Check key content is present
+ if !containsStr(html, "日账单") {
+ t.Error("expected period name in HTML")
+ }
+ if !containsStr(html, "致 test@example.com") || !containsStr(html, "To test@example.com") {
+ t.Error("expected recipient greeting in HTML")
+ }
+ if !containsStr(html, "claude-sonnet-4-20250514") {
+ t.Error("expected model name in HTML")
+ }
+ if !containsStr(html, "$1.5000") {
+ t.Error("expected total cost in HTML")
+ }
+ if !containsStr(html, "$8.5000") {
+ t.Error("expected balance in HTML")
+ }
+}
+
+func containsStr(s, substr string) bool {
+ return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && findSubstr(s, substr))
+}
+
+func findSubstr(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index 8eb90a6ba24..a921facb1f0 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -382,6 +382,15 @@ const (
// Web Search Emulation
SettingKeyWebSearchEmulationConfig = "web_search_emulation_config" // JSON 配置
+
+ // User Timezone Preference (用户时区偏好)
+ SettingKeyUserTimezonePrefix = "user_timezone:" // + userID
+
+ // Billing Statement Email (账单邮件)
+ SettingKeyBillingStatementEmailConfig = "billing_statement_email_config" // JSON 配置
+
+ // Billing Statement User Preference (用户账单邮件偏好)
+ SettingKeyBillingStatementUserPreferencePrefix = "billing_statement_user_pref:" // + userID
)
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 283a239b8d1..8e6cc376dc2 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -19,6 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/imroc/req/v3"
"golang.org/x/sync/singleflight"
)
@@ -627,6 +628,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyAvailableChannelsEnabled,
SettingKeyAffiliateEnabled,
SettingKeyRiskControlEnabled,
+ SettingKeyBillingStatementEmailConfig,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -677,6 +679,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
balanceLowNotifyThreshold = v
}
+ billingStatementCfg := ParseBillingStatementEmailConfig(settings[SettingKeyBillingStatementEmailConfig])
+ billingStatementEnabled := billingStatementCfg.Enabled
return &PublicSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
@@ -728,13 +732,29 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
-
- AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
-
- RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
+ AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
+ RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
+
+ BillingStatementEmailEnabled: billingStatementEnabled,
+ BillingStatementDailyEnabled: billingStatementEnabled && billingStatementCfg.DailyEnabled,
+ BillingStatementWeeklyEnabled: billingStatementEnabled && billingStatementCfg.WeeklyEnabled,
+ BillingStatementMonthlyEnabled: billingStatementEnabled && billingStatementCfg.MonthlyEnabled,
+ ServerTimezone: defaultPublicServerTimezone(),
}, nil
}
+func defaultPublicServerTimezone() string {
+ if name := strings.TrimSpace(timezone.Name()); name != "" && name != "Local" {
+ return name
+ }
+ if loc := timezone.Location(); loc != nil {
+ if name := strings.TrimSpace(loc.String()); name != "" && name != "Local" {
+ return name
+ }
+ }
+ return "UTC"
+}
+
// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
const (
@@ -882,11 +902,16 @@ type PublicSettingsInjectionPayload struct {
// Feature flags — MUST match the opt-in/opt-out registry in
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
// that hid the "可用渠道" menu on page refresh.
- ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
- ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
- AvailableChannelsEnabled bool `json:"available_channels_enabled"`
- AffiliateEnabled bool `json:"affiliate_enabled"`
- RiskControlEnabled bool `json:"risk_control_enabled"`
+ ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
+ ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
+ AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+ AffiliateEnabled bool `json:"affiliate_enabled"`
+ RiskControlEnabled bool `json:"risk_control_enabled"`
+ BillingStatementEmailEnabled bool `json:"billing_statement_email_enabled"`
+ BillingStatementDailyEnabled bool `json:"billing_statement_daily_enabled"`
+ BillingStatementWeeklyEnabled bool `json:"billing_statement_weekly_enabled"`
+ BillingStatementMonthlyEnabled bool `json:"billing_statement_monthly_enabled"`
+ ServerTimezone string `json:"server_timezone"`
}
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
@@ -948,6 +973,11 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
+ BillingStatementEmailEnabled: settings.BillingStatementEmailEnabled,
+ BillingStatementDailyEnabled: settings.BillingStatementDailyEnabled,
+ BillingStatementWeeklyEnabled: settings.BillingStatementWeeklyEnabled,
+ BillingStatementMonthlyEnabled: settings.BillingStatementMonthlyEnabled,
+ ServerTimezone: settings.ServerTimezone,
}, nil
}
@@ -1597,6 +1627,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
+ if settings.BillingStatementEmailConfig != "" {
+ updates[SettingKeyBillingStatementEmailConfig] = settings.BillingStatementEmailConfig
+ }
+
return updates, nil
}
@@ -2764,6 +2798,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result.AccountQuotaNotifyEmails = []NotifyEmailEntry{}
}
+ result.BillingStatementEmailConfig = settings[SettingKeyBillingStatementEmailConfig]
+
return result
}
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 80b8b32a901..a02be6febf1 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -193,6 +193,9 @@ type SystemSettings struct {
// Account quota notification
AccountQuotaNotifyEnabled bool
AccountQuotaNotifyEmails []NotifyEmailEntry
+
+ // Billing statement email config (JSON)
+ BillingStatementEmailConfig string
}
type DefaultSubscriptionSetting struct {
@@ -262,6 +265,14 @@ type PublicSettings struct {
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
+
+ // User-facing billing statement email availability. These expose only whether
+ // each period is globally available, not the admin cron schedule.
+ BillingStatementEmailEnabled bool `json:"billing_statement_email_enabled"`
+ BillingStatementDailyEnabled bool `json:"billing_statement_daily_enabled"`
+ BillingStatementWeeklyEnabled bool `json:"billing_statement_weekly_enabled"`
+ BillingStatementMonthlyEnabled bool `json:"billing_statement_monthly_enabled"`
+ ServerTimezone string `json:"server_timezone"`
}
type LoginAgreementDocument struct {
diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go
index f98336111ba..d3a523976fd 100644
--- a/backend/internal/service/user.go
+++ b/backend/internal/service/user.go
@@ -58,6 +58,12 @@ type User struct {
// 避免每请求查 DB。字段不持久化到数据库。
UserGroupRPMOverride *int
+ // 账单邮件偏好(不持久化到 users 表,从 setting 表 hydrate)
+ BillingStatementDailyEnabled bool
+ BillingStatementWeeklyEnabled bool
+ BillingStatementMonthlyEnabled bool
+ Timezone string
+
APIKeys []APIKey
Subscriptions []UserSubscription
}
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
index f84e6f0ab06..f22ed9c3346 100644
--- a/backend/internal/service/user_service.go
+++ b/backend/internal/service/user_service.go
@@ -7,6 +7,7 @@ import (
"crypto/subtle"
"encoding/base64"
"encoding/hex"
+ "encoding/json"
"fmt"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -24,6 +25,7 @@ import (
"sync"
"time"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
xdraw "golang.org/x/image/draw"
"golang.org/x/sync/singleflight"
)
@@ -173,6 +175,12 @@ type UpdateProfileRequest struct {
Concurrency *int `json:"concurrency"`
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
+ Timezone *string `json:"timezone"`
+
+ // Billing statement email preferences
+ BillingStatementDailyEnabled *bool `json:"billing_statement_daily_enabled"`
+ BillingStatementWeeklyEnabled *bool `json:"billing_statement_weekly_enabled"`
+ BillingStatementMonthlyEnabled *bool `json:"billing_statement_monthly_enabled"`
}
type UserAvatar struct {
@@ -242,6 +250,8 @@ func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, erro
if err := s.hydrateUserAvatar(ctx, user); err != nil {
return nil, fmt.Errorf("get user avatar: %w", err)
}
+ s.hydrateBillingStatementPreference(ctx, user)
+ s.hydrateUserTimezone(ctx, user)
return user, nil
}
@@ -454,10 +464,147 @@ func (s *UserService) updateProfile(ctx context.Context, userID int64, req Updat
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, oldConcurrency, fmt.Errorf("update user: %w", err)
}
+ if req.Timezone != nil {
+ if err := s.updateUserTimezone(ctx, userID, *req.Timezone); err != nil {
+ return nil, oldConcurrency, fmt.Errorf("update user timezone: %w", err)
+ }
+ }
+
+ // Update billing statement preferences if any field is provided
+ if req.BillingStatementDailyEnabled != nil || req.BillingStatementWeeklyEnabled != nil || req.BillingStatementMonthlyEnabled != nil {
+ if err := s.updateBillingStatementPreference(ctx, userID, req); err != nil {
+ return nil, oldConcurrency, fmt.Errorf("update billing statement preference: %w", err)
+ }
+ }
+ s.hydrateBillingStatementPreference(ctx, user)
+ s.hydrateUserTimezone(ctx, user)
return user, oldConcurrency, nil
}
+// hydrateBillingStatementPreference reads the user's billing statement preference from settings
+// and populates the User struct fields. Defaults to all-disabled if not found.
+func (s *UserService) hydrateBillingStatementPreference(ctx context.Context, user *User) {
+ if user == nil || s.settingRepo == nil {
+ return
+ }
+ key := SettingKeyBillingStatementUserPreferencePrefix + strconv.FormatInt(user.ID, 10)
+ raw, err := s.settingRepo.GetValue(ctx, key)
+ if err != nil || strings.TrimSpace(raw) == "" {
+ pref := DefaultBillingStatementUserPreference()
+ user.BillingStatementDailyEnabled = pref.DailyEnabled
+ user.BillingStatementWeeklyEnabled = pref.WeeklyEnabled
+ user.BillingStatementMonthlyEnabled = pref.MonthlyEnabled
+ return
+ }
+ pref := ParseBillingStatementUserPreference(raw)
+ user.BillingStatementDailyEnabled = pref.DailyEnabled
+ user.BillingStatementWeeklyEnabled = pref.WeeklyEnabled
+ user.BillingStatementMonthlyEnabled = pref.MonthlyEnabled
+}
+
+// updateBillingStatementPreference updates the user's billing statement preference in settings.
+func (s *UserService) updateBillingStatementPreference(ctx context.Context, userID int64, req UpdateProfileRequest) error {
+ if s.settingRepo == nil {
+ return nil
+ }
+ key := SettingKeyBillingStatementUserPreferencePrefix + strconv.FormatInt(userID, 10)
+ adminCfg, err := s.settingRepo.GetValue(ctx, SettingKeyBillingStatementEmailConfig)
+ if err == nil {
+ cfg := ParseBillingStatementEmailConfig(adminCfg)
+ if !cfg.Enabled {
+ // Global feature disabled: keep stored user preference untouched.
+ return nil
+ }
+ if req.BillingStatementDailyEnabled != nil && !cfg.DailyEnabled {
+ req.BillingStatementDailyEnabled = nil
+ }
+ if req.BillingStatementWeeklyEnabled != nil && !cfg.WeeklyEnabled {
+ req.BillingStatementWeeklyEnabled = nil
+ }
+ if req.BillingStatementMonthlyEnabled != nil && !cfg.MonthlyEnabled {
+ req.BillingStatementMonthlyEnabled = nil
+ }
+ }
+
+ // Read existing preference
+ raw, _ := s.settingRepo.GetValue(ctx, key)
+ pref := ParseBillingStatementUserPreference(raw)
+
+ // Apply updates
+ if req.BillingStatementDailyEnabled != nil {
+ pref.DailyEnabled = *req.BillingStatementDailyEnabled
+ }
+ if req.BillingStatementWeeklyEnabled != nil {
+ pref.WeeklyEnabled = *req.BillingStatementWeeklyEnabled
+ }
+ if req.BillingStatementMonthlyEnabled != nil {
+ pref.MonthlyEnabled = *req.BillingStatementMonthlyEnabled
+ }
+
+ // Serialize and save
+ data, err := json.Marshal(pref)
+ if err != nil {
+ return fmt.Errorf("marshal billing statement preference: %w", err)
+ }
+ return s.settingRepo.Set(ctx, key, string(data))
+}
+
+func userTimezoneSettingKey(userID int64) string {
+ return SettingKeyUserTimezonePrefix + strconv.FormatInt(userID, 10)
+}
+
+func defaultUserTimezone() string {
+ if name := strings.TrimSpace(timezone.Name()); name != "" && name != "Local" {
+ return name
+ }
+ if loc := timezone.Location(); loc != nil {
+ if name := strings.TrimSpace(loc.String()); name != "" && name != "Local" {
+ return name
+ }
+ }
+ return "UTC"
+}
+
+func normalizeUserTimezone(value string) (string, error) {
+ tz := strings.TrimSpace(value)
+ if tz == "" {
+ return defaultUserTimezone(), nil
+ }
+ if _, err := time.LoadLocation(tz); err != nil {
+ return "", infraerrors.BadRequest("USER_TIMEZONE_INVALID", "timezone must be a valid IANA timezone name")
+ }
+ return tz, nil
+}
+
+func (s *UserService) hydrateUserTimezone(ctx context.Context, user *User) {
+ if user == nil {
+ return
+ }
+ user.Timezone = defaultUserTimezone()
+ if s.settingRepo == nil {
+ return
+ }
+ raw, err := s.settingRepo.GetValue(ctx, userTimezoneSettingKey(user.ID))
+ if err != nil || strings.TrimSpace(raw) == "" {
+ return
+ }
+ if tz, err := normalizeUserTimezone(raw); err == nil {
+ user.Timezone = tz
+ }
+}
+
+func (s *UserService) updateUserTimezone(ctx context.Context, userID int64, value string) error {
+ if s.settingRepo == nil {
+ return nil
+ }
+ tz, err := normalizeUserTimezone(value)
+ if err != nil {
+ return err
+ }
+ return s.settingRepo.Set(ctx, userTimezoneSettingKey(userID), tz)
+}
+
func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) {
avatarValue := strings.TrimSpace(raw)
if avatarValue == "" {
@@ -951,6 +1098,8 @@ func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
if err := s.hydrateUserAvatar(ctx, user); err != nil {
return nil, fmt.Errorf("get user avatar: %w", err)
}
+ s.hydrateBillingStatementPreference(ctx, user)
+ s.hydrateUserTimezone(ctx, user)
return user, nil
}
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index dc96be0c063..f98b4b4c15e 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -515,6 +515,7 @@ var ProviderSet = wire.NewSet(
NewPaymentService,
ProvidePaymentOrderExpiryService,
ProvideBalanceNotifyService,
+ ProvideBillingStatementEmailService,
ProvideChannelMonitorService,
ProvideChannelMonitorRunner,
NewChannelMonitorRequestTemplateService,
@@ -547,6 +548,60 @@ func ProvideChannelMonitorService(
return NewChannelMonitorService(repo, encryptor)
}
+// ProvideBillingStatementEmailService creates and starts BillingStatementEmailService.
+func ProvideBillingStatementEmailService(
+ settingRepo SettingRepository,
+ userRepo UserRepository,
+ groupRepo GroupRepository,
+ usageRepo UsageLogRepository,
+ emailService *EmailService,
+ redisClient *redis.Client,
+ cfg *config.Config,
+) *BillingStatementEmailService {
+ svc := NewBillingStatementEmailService(settingRepo, userRepo, groupRepo, usageRepo, emailService, billingStatementRedisAdapter{client: redisClient}, cfg)
+ svc.Start()
+ return svc
+}
+
+type billingStatementRedisAdapter struct {
+ client *redis.Client
+}
+
+var billingStatementReleaseScript = redis.NewScript(`
+if redis.call("GET", KEYS[1]) == ARGV[1] then
+ return redis.call("DEL", KEYS[1])
+end
+return 0
+`)
+
+func (a billingStatementRedisAdapter) SetNX(ctx context.Context, key string, value any, expiration time.Duration) (bool, error) {
+ if a.client == nil {
+ return false, nil
+ }
+ return a.client.SetNX(ctx, key, value, expiration).Result()
+}
+
+func (a billingStatementRedisAdapter) Get(ctx context.Context, key string) (string, error) {
+ if a.client == nil {
+ return "", nil
+ }
+ return a.client.Get(ctx, key).Result()
+}
+
+func (a billingStatementRedisAdapter) Set(ctx context.Context, key string, value any, expiration time.Duration) error {
+ if a.client == nil {
+ return nil
+ }
+ return a.client.Set(ctx, key, value, expiration).Err()
+}
+
+func (a billingStatementRedisAdapter) ReleaseIfValue(ctx context.Context, key string, value string) error {
+ if a.client == nil {
+ return nil
+ }
+ return billingStatementReleaseScript.Run(ctx, a.client, []string{key}, value).Err()
+}
+
// ProvideChannelMonitorRunner 创建并启动渠道监控调度器。
// 通过 SetScheduler 注入回 service 后再 Start,确保启动时加载所有 enabled monitor,
// 后续 CRUD 也能即时同步任务表。Runner.Stop 由 cleanup function 调用。
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 01d6969db5b..43a66db4143 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -526,6 +526,9 @@ export interface SystemSettings {
// OpenAI fast/flex policy
openai_fast_policy_settings?: OpenAIFastPolicySettings;
+
+ // Billing statement email config
+ billing_statement_email_config?: string;
}
export interface UpdateSettingsRequest {
@@ -719,6 +722,9 @@ export interface UpdateSettingsRequest {
// OpenAI fast/flex policy
openai_fast_policy_settings?: OpenAIFastPolicySettings;
+
+ // Billing statement email config
+ billing_statement_email_config?: string;
}
/**
diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts
index da7a91eb46c..07b44cc6854 100644
--- a/frontend/src/api/user.ts
+++ b/frontend/src/api/user.ts
@@ -38,6 +38,10 @@ export async function updateProfile(profile: {
balance_notify_enabled?: boolean
balance_notify_threshold?: number | null
balance_notify_extra_emails?: NotifyEmailEntry[]
+ timezone?: string
+ billing_statement_daily_enabled?: boolean
+ billing_statement_weekly_enabled?: boolean
+ billing_statement_monthly_enabled?: boolean
}): Promise {
const { data } = await apiClient.put('/user', profile)
return data
diff --git a/frontend/src/components/account/QuotaDimensionRow.vue b/frontend/src/components/account/QuotaDimensionRow.vue
index e7fe2d0bc8d..d44c0c889a5 100644
--- a/frontend/src/components/account/QuotaDimensionRow.vue
+++ b/frontend/src/components/account/QuotaDimensionRow.vue
@@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
+import { getTimezoneOffsetLabel } from '@/constants/timezone'
const { t } = useI18n()
@@ -54,16 +55,6 @@ const onModeChange = (e: Event) => {
}
}
-function getTimezoneOffsetLabel(tz: string): string {
- try {
- const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
- const parts = dtf.formatToParts(new Date())
- const tzPart = parts.find(p => p.type === 'timeZoneName')
- return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : ''
- } catch {
- return ''
- }
-}
diff --git a/frontend/src/components/account/QuotaLimitCard.vue b/frontend/src/components/account/QuotaLimitCard.vue
index 68a68f29039..2a86460f364 100644
--- a/frontend/src/components/account/QuotaLimitCard.vue
+++ b/frontend/src/components/account/QuotaLimitCard.vue
@@ -3,6 +3,7 @@ import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import QuotaDimensionRow from './QuotaDimensionRow.vue'
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
+import { COMMON_TIMEZONE_OPTIONS } from '@/constants/timezone'
const { t } = useI18n()
@@ -90,13 +91,7 @@ watch(localEnabled, (val) => {
}
})
-// Common timezone options
-const timezoneOptions = [
- 'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
- 'Asia/Dubai', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow',
- 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
- 'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland',
-]
+const timezoneOptions = [...COMMON_TIMEZONE_OPTIONS]
// Hours for dropdown (0-23)
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
diff --git a/frontend/src/components/user/profile/ProfileBillingStatementCard.vue b/frontend/src/components/user/profile/ProfileBillingStatementCard.vue
new file mode 100644
index 00000000000..99861d3d747
--- /dev/null
+++ b/frontend/src/components/user/profile/ProfileBillingStatementCard.vue
@@ -0,0 +1,78 @@
+
+
+
+
+ {{ t('profile.billingStatement.title') }}
+
+
+ {{ t('profile.billingStatement.description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/user/profile/ProfileEditForm.vue b/frontend/src/components/user/profile/ProfileEditForm.vue
index e1441921f26..fa8721a5300 100644
--- a/frontend/src/components/user/profile/ProfileEditForm.vue
+++ b/frontend/src/components/user/profile/ProfileEditForm.vue
@@ -28,6 +28,24 @@
/>
+
+
+
+
+ {{ t('profile.timezoneHelp', { timezone: serverTimezone || 'UTC' }) }}
+
+
+