diff --git a/internal/application/forward/usecases/getsubscriptionforwardusage.go b/internal/application/forward/usecases/getsubscriptionforwardusage.go index 925a92c..69e8d22 100644 --- a/internal/application/forward/usecases/getsubscriptionforwardusage.go +++ b/internal/application/forward/usecases/getsubscriptionforwardusage.go @@ -134,10 +134,13 @@ func (uc *GetSubscriptionForwardUsageUseCase) Execute(ctx context.Context, query return nil, fmt.Errorf("failed to get rule count: %w", err) } - // Query traffic usage from Redis (recent) and MySQL stats (historical) + // Query traffic usage from Redis (recent) and MySQL stats (historical). + // Use the resolved *traffic cycle* (calendar_month or billing_cycle) so + // the displayed traffic_used matches what quota enforcement counts. var trafficUsed uint64 - periodStart := sub.CurrentPeriodStart() - periodEnd := biztime.EndOfDayUTC(sub.CurrentPeriodEnd()) + cycle := subscription.ResolveTrafficPeriod(plan, sub) + periodStart := cycle.Start + periodEnd := biztime.EndOfDayUTC(cycle.End) now := biztime.NowUTC() // Use start of yesterday's business day as batch/speed boundary (Lambda architecture) diff --git a/internal/application/forward/usecases/getuserforwardusage.go b/internal/application/forward/usecases/getuserforwardusage.go index 80d32c2..2871a61 100644 --- a/internal/application/forward/usecases/getuserforwardusage.go +++ b/internal/application/forward/usecases/getuserforwardusage.go @@ -124,10 +124,14 @@ func (uc *GetUserForwardUsageUseCase) Execute(ctx context.Context, query GetUser continue } - // Collect forward subscription ID and period range + // Collect forward subscription ID and traffic cycle range. + // Use ResolveTrafficPeriod so the aggregated traffic_used matches the + // cycle that quota enforcement actually counts (calendar_month or + // billing_cycle), not the subscription billing period. forwardSubscriptionIDs = append(forwardSubscriptionIDs, sub.ID()) - periodStart := sub.CurrentPeriodStart() - periodEnd := sub.CurrentPeriodEnd() + cycle := subscription.ResolveTrafficPeriod(plan, sub) + periodStart := cycle.Start + periodEnd := cycle.End if firstSub || periodStart.Before(earliestFrom) { earliestFrom = periodStart } diff --git a/internal/application/subscription/dto/dto.go b/internal/application/subscription/dto/dto.go index 6c21831..d9f072b 100644 --- a/internal/application/subscription/dto/dto.go +++ b/internal/application/subscription/dto/dto.go @@ -17,30 +17,38 @@ type SubscriptionUserDTO struct { } type SubscriptionDTO struct { - SID string `json:"id"` // Stripe-style ID: sub_xxx - UserSID string `json:"user_id"` // User's Stripe-style ID (kept for backward compatibility) - User *SubscriptionUserDTO `json:"user,omitempty"` // Embedded user information - UUID string `json:"uuid"` // UUID for internal use (kept for backward compatibility) - LinkToken string `json:"link_token"` - SubscribeURL string `json:"subscribe_url"` - Plan *PlanDTO `json:"plan,omitempty"` - Status string `json:"status"` - BillingCycle *string `json:"billing_cycle,omitempty"` // Billing cycle: weekly, monthly, quarterly, semi_annual, yearly, lifetime - StartDate time.Time `json:"start_date"` - EndDate time.Time `json:"end_date"` - AutoRenew bool `json:"auto_renew"` - CurrentPeriodStart time.Time `json:"current_period_start"` - CurrentPeriodEnd time.Time `json:"current_period_end"` - IsExpired bool `json:"is_expired"` - IsActive bool `json:"is_active"` - DataUsedBytes uint64 `json:"data_used_bytes"` // Current traffic used in bytes - DataLimitBytes uint64 `json:"data_limit_bytes"` // Traffic limit in bytes (0=unlimited) - OnlineDeviceCount int `json:"online_device_count"` // Current online device count - DeviceLimit int `json:"device_limit"` // Max concurrent devices (0=unlimited) - CancelledAt *time.Time `json:"cancelled_at,omitempty"` - CancelReason *string `json:"cancel_reason,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + SID string `json:"id"` // Stripe-style ID: sub_xxx + UserSID string `json:"user_id"` // User's Stripe-style ID (kept for backward compatibility) + User *SubscriptionUserDTO `json:"user,omitempty"` // Embedded user information + UUID string `json:"uuid"` // UUID for internal use (kept for backward compatibility) + LinkToken string `json:"link_token"` + SubscribeURL string `json:"subscribe_url"` + Plan *PlanDTO `json:"plan,omitempty"` + Status string `json:"status"` + BillingCycle *string `json:"billing_cycle,omitempty"` // Billing cycle: weekly, monthly, quarterly, semi_annual, yearly, lifetime + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + AutoRenew bool `json:"auto_renew"` + // Subscription billing period (used by billing/renewal flows). + CurrentPeriodStart time.Time `json:"current_period_start"` + CurrentPeriodEnd time.Time `json:"current_period_end"` + // Current traffic cycle window (matches DataUsedBytes / CurrentCycle*Bytes). + // For calendar_month reset mode this is the business-timezone calendar month + // and differs from CurrentPeriodStart/End. For billing_cycle mode it matches. + CurrentTrafficCycleStart time.Time `json:"current_traffic_cycle_start"` + CurrentTrafficCycleEnd time.Time `json:"current_traffic_cycle_end"` + IsExpired bool `json:"is_expired"` + IsActive bool `json:"is_active"` + DataUsedBytes uint64 `json:"data_used_bytes"` // Total traffic used in current cycle (= upload + download, plus admin adjustment) + DataLimitBytes uint64 `json:"data_limit_bytes"` // Traffic limit in bytes (0=unlimited) + CurrentCycleUploadBytes uint64 `json:"current_cycle_upload_bytes"` // Upload traffic in current cycle (raw, no adjustment) + CurrentCycleDownloadBytes uint64 `json:"current_cycle_download_bytes"` // Download traffic in current cycle (raw, no adjustment) + OnlineDeviceCount int `json:"online_device_count"` // Current online device count + DeviceLimit int `json:"device_limit"` // Max concurrent devices (0=unlimited) + CancelledAt *time.Time `json:"cancelled_at,omitempty"` + CancelReason *string `json:"cancel_reason,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PlanDTO struct { @@ -172,6 +180,29 @@ func WithDataUsage(used, limit uint64) SubscriptionDTOOption { } } +// WithDataUsageBreakdown sets the upload/download breakdown along with the +// adjusted total used and the limit. Use this in preference to WithDataUsage +// whenever upload/download figures are available so the frontend doesn't need +// a separate traffic-stats request. +func WithDataUsageBreakdown(used, upload, download, limit uint64) SubscriptionDTOOption { + return func(d *SubscriptionDTO) { + d.DataUsedBytes = used + d.DataLimitBytes = limit + d.CurrentCycleUploadBytes = upload + d.CurrentCycleDownloadBytes = download + } +} + +// WithTrafficCycle sets the current traffic cycle window (resolved via +// subscription.ResolveTrafficPeriod). This window matches DataUsedBytes and +// the upload/download breakdown — it is NOT the subscription billing period. +func WithTrafficCycle(start, end time.Time) SubscriptionDTOOption { + return func(d *SubscriptionDTO) { + d.CurrentTrafficCycleStart = start + d.CurrentTrafficCycleEnd = end + } +} + func toSubscriptionDTOInternal(sub *subscription.Subscription, plan *subscription.Plan, u *user.User, baseURL string, opts ...SubscriptionDTOOption) *SubscriptionDTO { if sub == nil { return nil diff --git a/internal/application/subscription/services/quotacachesync.go b/internal/application/subscription/services/quotacachesync.go index 7d91e3c..d22bc23 100644 --- a/internal/application/subscription/services/quotacachesync.go +++ b/internal/application/subscription/services/quotacachesync.go @@ -63,11 +63,14 @@ func (s *QuotaCacheSyncService) SyncQuotaFromSubscription(ctx context.Context, s } } - // Build cached quota object + // Cache the *traffic cycle* (not the billing period) so consumers like the + // node hub real-time enforcer query usage over the correct window. For + // calendar_month plans the traffic cycle differs from the billing period. + cycle := subscription.ResolveTrafficPeriod(plan, sub) quota := &cache.CachedQuota{ Limit: int64(trafficLimit), - PeriodStart: sub.CurrentPeriodStart(), - PeriodEnd: sub.CurrentPeriodEnd(), + PeriodStart: cycle.Start, + PeriodEnd: cycle.End, PlanType: plan.PlanType().String(), Suspended: false, // Only set to false when syncing (active subscription) } @@ -130,11 +133,12 @@ func (s *QuotaCacheSyncService) LoadQuotaByID(ctx context.Context, subscriptionI } } - // Build cached quota object + // Cache the *traffic cycle* (see SyncQuotaFromSubscription for rationale). + cycle := subscription.ResolveTrafficPeriod(plan, sub) quota := &cache.CachedQuota{ Limit: int64(trafficLimit), - PeriodStart: sub.CurrentPeriodStart(), - PeriodEnd: sub.CurrentPeriodEnd(), + PeriodStart: cycle.Start, + PeriodEnd: cycle.End, PlanType: plan.PlanType().String(), Suspended: false, } diff --git a/internal/application/subscription/usecases/getsubscription.go b/internal/application/subscription/usecases/getsubscription.go index 64a7118..cf1baee 100644 --- a/internal/application/subscription/usecases/getsubscription.go +++ b/internal/application/subscription/usecases/getsubscription.go @@ -15,13 +15,13 @@ type GetSubscriptionQuery struct { } type GetSubscriptionUseCase struct { - subscriptionRepo subscription.SubscriptionRepository - planRepo subscription.PlanRepository - userRepo user.Repository - onlineDeviceCounter OnlineDeviceCounter // optional, nil-safe - quotaService QuotaService // optional, nil-safe - logger logger.Interface - baseURL string + subscriptionRepo subscription.SubscriptionRepository + planRepo subscription.PlanRepository + userRepo user.Repository + onlineDeviceCounter OnlineDeviceCounter // optional, nil-safe + quotaService QuotaService // optional, nil-safe + logger logger.Interface + baseURL string } func NewGetSubscriptionUseCase( @@ -140,13 +140,19 @@ func (uc *GetSubscriptionUseCase) buildDTOOptions(ctx context.Context, subID uin } } - // Query data usage from QuotaService + // Query data usage from QuotaService — provides usage figures AND the + // traffic cycle window they were aggregated over (calendar_month or + // billing_cycle, depending on plan). Both must be exposed together so the + // frontend can label the figures with the correct window. if uc.quotaService != nil { quota, err := uc.quotaService.GetSubscriptionQuota(ctx, subID) if err != nil { uc.logger.Warnw("failed to get subscription quota", "error", err, "subscription_id", subID) } else if quota != nil { - opts = append(opts, dto.WithDataUsage(quota.UsedBytes, quota.LimitBytes)) + opts = append(opts, + dto.WithDataUsageBreakdown(quota.UsedBytes, quota.UploadBytes, quota.DownloadBytes, quota.LimitBytes), + dto.WithTrafficCycle(quota.PeriodStart, quota.PeriodEnd), + ) } } diff --git a/internal/application/subscription/usecases/listusersubscriptions.go b/internal/application/subscription/usecases/listusersubscriptions.go index 8c3b846..c9b11c2 100644 --- a/internal/application/subscription/usecases/listusersubscriptions.go +++ b/internal/application/subscription/usecases/listusersubscriptions.go @@ -30,10 +30,10 @@ type ListUserSubscriptionsQuery struct { } type ListUserSubscriptionsResult struct { - Subscriptions []*dto.SubscriptionDTO `json:"subscriptions"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` + Subscriptions []*dto.SubscriptionDTO `json:"subscriptions"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` StatusCounts *dto.SubscriptionStatusCounts `json:"status_counts,omitempty"` // Present when IncludeCounts is true } @@ -179,13 +179,18 @@ func (uc *ListUserSubscriptionsUseCase) Execute(ctx context.Context, query ListU if count, ok := onlineCounts[sub.ID()]; ok { opts = append(opts, dto.WithOnlineDeviceCount(count)) } - // Set data usage from QuotaService + // Set data usage and traffic cycle window from QuotaService so the + // frontend doesn't need a separate traffic-stats request and can + // label figures with the cycle window that was actually aggregated. if uc.quotaService != nil && plan != nil { quota, err := uc.quotaService.GetSubscriptionQuotaPreloaded(ctx, sub, plan) if err != nil { uc.logger.Warnw("failed to get subscription quota", "error", err, "subscription_id", sub.ID()) } else if quota != nil { - opts = append(opts, dto.WithDataUsage(quota.UsedBytes, quota.LimitBytes)) + opts = append(opts, + dto.WithDataUsageBreakdown(quota.UsedBytes, quota.UploadBytes, quota.DownloadBytes, quota.LimitBytes), + dto.WithTrafficCycle(quota.PeriodStart, quota.PeriodEnd), + ) } } diff --git a/internal/application/subscription/usecases/quotaservice.go b/internal/application/subscription/usecases/quotaservice.go index bc33db1..67bdb90 100644 --- a/internal/application/subscription/usecases/quotaservice.go +++ b/internal/application/subscription/usecases/quotaservice.go @@ -14,14 +14,22 @@ import ( ) // QuotaCheckResult represents the quota usage status for a subscription. +// +// PeriodStart/PeriodEnd describe the *traffic cycle* the usage is aggregated +// over (resolved via subscription.ResolveTrafficPeriod). For calendar_month +// reset mode this is the business-timezone calendar month, NOT the subscription's +// billing period; callers must use these dates when displaying or persisting +// the matching usage figures, not sub.CurrentPeriodStart/End. type QuotaCheckResult struct { SubscriptionID uint // Internal subscription ID SubscriptionSID string // Stripe-style subscription ID PlanType string // Plan type (node, forward, hybrid) - UsedBytes uint64 // Total traffic used in current period + UsedBytes uint64 // Total traffic used in current period (= UploadBytes + DownloadBytes after adjustment) + UploadBytes uint64 // Upload traffic in current period (raw, no adjustment) + DownloadBytes uint64 // Download traffic in current period (raw, no adjustment) LimitBytes uint64 // Traffic limit (0 = unlimited) - PeriodStart time.Time // Current billing period start - PeriodEnd time.Time // Current billing period end + PeriodStart time.Time // Current traffic cycle start (calendar_month or billing_cycle) + PeriodEnd time.Time // Current traffic cycle end IsExceeded bool // Whether quota is exceeded RemainingBytes uint64 // Remaining traffic (0 if exceeded or unlimited) } @@ -284,8 +292,8 @@ func (s *QuotaServiceImpl) buildQuotaResult( // - Hybrid plan: count all resource types (node + forward_rule) resourceType := s.getResourceTypeForPlan(plan.PlanType()) - // Calculate period usage - usedBytes, err := s.calculatePeriodUsage( + // Calculate period usage (with upload/download breakdown) + totalBytes, uploadBytes, downloadBytes, err := s.calculatePeriodUsage( ctx, []uint{sub.ID()}, resourceType, @@ -302,7 +310,9 @@ func (s *QuotaServiceImpl) buildQuotaResult( return nil, err } - // Apply traffic used adjustment + // Apply traffic used adjustment to the aggregate total only; + // upload/download breakdown remains the raw observed split. + usedBytes := totalBytes if adj := sub.TrafficUsedAdjustment(); adj != 0 { adjusted := int64(usedBytes) + adj if adjusted < 0 { @@ -331,6 +341,8 @@ func (s *QuotaServiceImpl) buildQuotaResult( SubscriptionSID: sub.SID(), PlanType: plan.PlanType().String(), UsedBytes: usedBytes, + UploadBytes: uploadBytes, + DownloadBytes: downloadBytes, LimitBytes: limitBytes, PeriodStart: periodStart, PeriodEnd: periodEnd, @@ -369,7 +381,7 @@ func (s *QuotaServiceImpl) GetCurrentPeriodUsage( periodEnd time.Time, ) (int64, error) { // Aggregate all resource types (nil = no filter) - usage, err := s.calculatePeriodUsage(ctx, []uint{subscriptionID}, nil, periodStart, periodEnd) + usage, _, _, err := s.calculatePeriodUsage(ctx, []uint{subscriptionID}, nil, periodStart, periodEnd) if err != nil { return 0, err } @@ -380,22 +392,27 @@ func (s *QuotaServiceImpl) GetCurrentPeriodUsage( return int64(usage), nil } -// calculatePeriodUsage calculates total usage for subscriptions within a billing period. +// calculatePeriodUsage calculates total/upload/download usage for subscriptions +// within a traffic cycle. // Uses Redis HourlyTrafficCache for recent data (last 24h) and MySQL subscription_usage_stats // for historical data. This approach provides real-time accuracy for recent traffic while // efficiently querying pre-aggregated data for historical periods. // // resourceType: nil = aggregate all resource types (for Hybrid plans), // non-nil = filter by specific resource type (for Node/Forward plans). +// +// Returns (total, upload, download, err). total == upload + download under +// normal conditions; if either data source partially fails the returned values +// reflect what was successfully fetched. func (s *QuotaServiceImpl) calculatePeriodUsage( ctx context.Context, subscriptionIDs []uint, resourceType *string, periodStart time.Time, periodEnd time.Time, -) (uint64, error) { +) (uint64, uint64, uint64, error) { if len(subscriptionIDs) == 0 { - return 0, nil + return 0, 0, 0, nil } now := biztime.NowUTC() @@ -409,7 +426,7 @@ func (s *QuotaServiceImpl) calculatePeriodUsage( // MySQL: complete days before yesterday; Redis: yesterday + today (within 48h TTL) recentBoundary := biztime.StartOfDayUTC(now.AddDate(0, 0, -1)) - var total uint64 + var total, upload, download uint64 var redisErr, mysqlErr error // Determine time boundaries for recent data (yesterday + today from Redis) @@ -448,6 +465,8 @@ func (s *QuotaServiceImpl) calculatePeriodUsage( } else { for _, t := range recentTraffic { total += t.Total + upload += t.Upload + download += t.Download } } } @@ -475,13 +494,15 @@ func (s *QuotaServiceImpl) calculatePeriodUsage( // Continue with Redis data even if MySQL stats fail } else if historicalTraffic != nil { total += historicalTraffic.Total + upload += historicalTraffic.Upload + download += historicalTraffic.Download } } // If both data sources failed, return error to prevent false zero-usage if redisErr != nil && mysqlErr != nil { - return 0, fmt.Errorf("both traffic data sources failed: redis=%w, mysql=%v", redisErr, mysqlErr) + return 0, 0, 0, fmt.Errorf("both traffic data sources failed: redis=%w, mysql=%v", redisErr, mysqlErr) } - return total, nil + return total, upload, download, nil } diff --git a/internal/application/user/dto/dashboarddto.go b/internal/application/user/dto/dashboarddto.go index bcfca84..9943de6 100644 --- a/internal/application/user/dto/dashboarddto.go +++ b/internal/application/user/dto/dashboarddto.go @@ -19,15 +19,23 @@ type DashboardPlanDTO struct { Limits map[string]interface{} `json:"limits,omitempty"` } -// DashboardSubscriptionDTO represents subscription info with usage for dashboard +// DashboardSubscriptionDTO represents subscription info with usage for dashboard. +// +// CurrentPeriodStart/End are the subscription billing period. +// CurrentTrafficCycleStart/End are the window that Usage is aggregated over +// (resolved via subscription.ResolveTrafficPeriod). For calendar_month plans +// they differ from the billing period — frontend should display Usage paired +// with the traffic cycle window, not the billing window. type DashboardSubscriptionDTO struct { - SID string `json:"id"` - Plan *DashboardPlanDTO `json:"plan,omitempty"` - Status string `json:"status"` - CurrentPeriodStart time.Time `json:"current_period_start"` - CurrentPeriodEnd time.Time `json:"current_period_end"` - IsActive bool `json:"is_active"` - Usage *UsageSummary `json:"usage"` + SID string `json:"id"` + Plan *DashboardPlanDTO `json:"plan,omitempty"` + Status string `json:"status"` + CurrentPeriodStart time.Time `json:"current_period_start"` + CurrentPeriodEnd time.Time `json:"current_period_end"` + CurrentTrafficCycleStart time.Time `json:"current_traffic_cycle_start"` + CurrentTrafficCycleEnd time.Time `json:"current_traffic_cycle_end"` + IsActive bool `json:"is_active"` + Usage *UsageSummary `json:"usage"` } // DashboardResponse represents the user dashboard response diff --git a/internal/application/user/usecases/getdashboard.go b/internal/application/user/usecases/getdashboard.go index eab8760..2266a46 100644 --- a/internal/application/user/usecases/getdashboard.go +++ b/internal/application/user/usecases/getdashboard.go @@ -117,14 +117,21 @@ func (uc *GetDashboardUseCase) Execute( response.TotalUsage.Download += subUsage.Download response.TotalUsage.Total += subUsage.Total + // Resolve traffic cycle so the DTO carries both the billing period and + // the actual window Usage was aggregated over (they differ for + // calendar_month plans). + cycle := subscription.ResolveTrafficPeriod(planMap[sub.PlanID()], sub) + // Build subscription DTO subDTO := &dto.DashboardSubscriptionDTO{ - SID: sub.SID(), - Status: sub.EffectiveStatus().String(), - CurrentPeriodStart: sub.CurrentPeriodStart(), - CurrentPeriodEnd: sub.CurrentPeriodEnd(), - IsActive: sub.IsActive(), - Usage: subUsage, + SID: sub.SID(), + Status: sub.EffectiveStatus().String(), + CurrentPeriodStart: sub.CurrentPeriodStart(), + CurrentPeriodEnd: sub.CurrentPeriodEnd(), + CurrentTrafficCycleStart: cycle.Start, + CurrentTrafficCycleEnd: cycle.End, + IsActive: sub.IsActive(), + Usage: subUsage, } // Add plan info if available diff --git a/internal/domain/subscription/trafficperiod_test.go b/internal/domain/subscription/trafficperiod_test.go new file mode 100644 index 0000000..3927e24 --- /dev/null +++ b/internal/domain/subscription/trafficperiod_test.go @@ -0,0 +1,161 @@ +package subscription + +import ( + "testing" + "time" + + vo "github.com/orris-inc/orris/internal/domain/subscription/valueobjects" + "github.com/orris-inc/orris/internal/shared/biztime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// trafficCyclePlan builds a Plan whose traffic_reset_mode is the given value. +// Pass empty string to leave the mode unset (caller verifies fallback behavior). +func trafficCyclePlan(t *testing.T, mode string) *Plan { + t.Helper() + + plan, err := NewPlan("Test Plan", "test", "desc", vo.PlanTypeNode) + require.NoError(t, err) + + features := vo.NewPlanFeatures(nil) + if mode != "" { + require.NoError(t, features.SetTrafficResetMode(mode)) + } + require.NoError(t, plan.UpdateFeatures(features)) + return plan +} + +// trafficCycleSubscription builds an active monthly subscription with the +// given billing-period bounds. The cycle dates are also used as start/end so +// the subscription validates without renewal handling. +func trafficCycleSubscription(t *testing.T, periodStart, periodEnd time.Time) *Subscription { + t.Helper() + + bc, err := vo.NewBillingCycle("monthly") + require.NoError(t, err) + + sub, err := ReconstructSubscriptionWithParams(SubscriptionReconstructParams{ + ID: 1, + UserID: 10, + PlanID: 100, + SubjectType: "user", + SubjectID: 10, + SID: "sub_test", + UUID: "00000000-0000-0000-0000-000000000001", + LinkToken: "dGVzdHRva2VuMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw", + Status: vo.StatusActive, + StartDate: periodStart, + EndDate: periodEnd, + AutoRenew: true, + CurrentPeriodStart: periodStart, + CurrentPeriodEnd: periodEnd, + BillingCycle: bc, + Version: 1, + CreatedAt: periodStart, + UpdatedAt: periodStart, + }) + require.NoError(t, err) + return sub +} + +func TestResolveTrafficPeriod_CalendarMonth_DiffersFromBillingPeriod(t *testing.T) { + // Subscription billing window mid-Jan to mid-Feb; calendar_month plan + // must resolve to the business-tz calendar month containing "now", + // NOT the subscription billing period. + periodStart := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + periodEnd := time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC) + + plan := trafficCyclePlan(t, "calendar_month") + sub := trafficCycleSubscription(t, periodStart, periodEnd) + + got := ResolveTrafficPeriod(plan, sub) + + bizNow := biztime.ToBizTimezone(biztime.NowUTC()) + expectedStart := biztime.StartOfMonthUTC(bizNow.Year(), bizNow.Month()) + expectedEnd := biztime.EndOfMonthUTC(bizNow.Year(), bizNow.Month()) + + // Today's calendar month start is far from Jan 15: confirm we're not + // silently returning the billing period. + assert.Equal(t, expectedStart, got.Start, "calendar_month plan must use calendar month start") + assert.Equal(t, expectedEnd, got.End, "calendar_month plan must use calendar month end") + assert.NotEqual(t, sub.CurrentPeriodStart(), got.Start, "calendar_month must NOT use billing period start") +} + +func TestResolveTrafficPeriod_CalendarMonth_FloorsToManualReset(t *testing.T) { + // If a manual reset moves CurrentPeriodStart past the calendar month + // start, that reset wins as a floor (excludes pre-reset traffic). + bizNow := biztime.ToBizTimezone(biztime.NowUTC()) + monthStart := biztime.StartOfMonthUTC(bizNow.Year(), bizNow.Month()) + resetAt := monthStart.AddDate(0, 0, 5) // 5 days into the month + + plan := trafficCyclePlan(t, "calendar_month") + sub := trafficCycleSubscription(t, resetAt, resetAt.AddDate(0, 1, 0)) + + got := ResolveTrafficPeriod(plan, sub) + + assert.Equal(t, resetAt, got.Start, "manual reset (post month-start) must floor the cycle start") + assert.Equal(t, biztime.EndOfMonthUTC(bizNow.Year(), bizNow.Month()), got.End) +} + +func TestResolveTrafficPeriod_BillingCycle_UsesSubscriptionPeriod(t *testing.T) { + periodStart := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + periodEnd := time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC) + + plan := trafficCyclePlan(t, "billing_cycle") + sub := trafficCycleSubscription(t, periodStart, periodEnd) + + got := ResolveTrafficPeriod(plan, sub) + + assert.Equal(t, periodStart, got.Start) + assert.Equal(t, periodEnd, got.End) +} + +func TestResolveTrafficPeriod_FallsBackToCalendarMonthOnNilPlan(t *testing.T) { + periodStart := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + periodEnd := time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC) + sub := trafficCycleSubscription(t, periodStart, periodEnd) + + got := ResolveTrafficPeriod(nil, sub) + + bizNow := biztime.ToBizTimezone(biztime.NowUTC()) + assert.Equal(t, biztime.StartOfMonthUTC(bizNow.Year(), bizNow.Month()), got.Start) + assert.Equal(t, biztime.EndOfMonthUTC(bizNow.Year(), bizNow.Month()), got.End) +} + +func TestResolveTrafficPeriod_LifetimeAlwaysUsesSubscriptionPeriod(t *testing.T) { + // Lifetime subscriptions must NEVER be reset by calendar_month even if + // the plan declares calendar_month — they accumulate from start to end. + periodStart := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) + periodEnd := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) + + bc, err := vo.NewBillingCycle("lifetime") + require.NoError(t, err) + sub, err := ReconstructSubscriptionWithParams(SubscriptionReconstructParams{ + ID: 2, + UserID: 10, + PlanID: 100, + SubjectType: "user", + SubjectID: 10, + SID: "sub_lifetime", + UUID: "00000000-0000-0000-0000-000000000002", + LinkToken: "dGVzdHRva2VuMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkx", + Status: vo.StatusActive, + StartDate: periodStart, + EndDate: periodEnd, + AutoRenew: false, + CurrentPeriodStart: periodStart, + CurrentPeriodEnd: periodEnd, + BillingCycle: bc, + Version: 1, + CreatedAt: periodStart, + UpdatedAt: periodStart, + }) + require.NoError(t, err) + + plan := trafficCyclePlan(t, "calendar_month") + got := ResolveTrafficPeriod(plan, sub) + + assert.Equal(t, periodStart, got.Start) + assert.Equal(t, periodEnd, got.End) +} diff --git a/internal/infrastructure/cache/subscriptionquotacache.go b/internal/infrastructure/cache/subscriptionquotacache.go index 5b177d3..1ac81a0 100644 --- a/internal/infrastructure/cache/subscriptionquotacache.go +++ b/internal/infrastructure/cache/subscriptionquotacache.go @@ -12,11 +12,18 @@ import ( "github.com/orris-inc/orris/internal/shared/logger" ) -// CachedQuota represents cached subscription quota information +// CachedQuota represents cached subscription quota information. +// +// PeriodStart/PeriodEnd describe the *traffic cycle* (resolved via +// subscription.ResolveTrafficPeriod), NOT the subscription billing period. +// For calendar_month reset mode they describe the business-timezone calendar +// month; for billing_cycle mode they match the subscription's current period. +// Producers must populate them via ResolveTrafficPeriod and consumers must +// treat them as the authoritative window for usage aggregation. type CachedQuota struct { Limit int64 // Traffic limit in bytes - PeriodStart time.Time // Billing period start - PeriodEnd time.Time // Billing period end + PeriodStart time.Time // Traffic cycle start + PeriodEnd time.Time // Traffic cycle end PlanType string // Plan type: node/forward/hybrid Suspended bool // Whether the subscription is suspended NotFound bool // Null marker: subscription confirmed not found/inactive in DB @@ -34,11 +41,11 @@ type SubscriptionQuotaCache interface { } const ( - quotaKeyPrefix = "subscription:quota:" - baseQuotaTTL = 60 * time.Minute - quotaTTLJitter = 20 * time.Minute // TTL range: 60-80 min (anti-stampede) - nullMarkerTTL = 2 * time.Minute // Short TTL for not-found markers (anti-penetration) - fieldLimit = "limit" + quotaKeyPrefix = "subscription:quota:" + baseQuotaTTL = 60 * time.Minute + quotaTTLJitter = 20 * time.Minute // TTL range: 60-80 min (anti-stampede) + nullMarkerTTL = 2 * time.Minute // Short TTL for not-found markers (anti-penetration) + fieldLimit = "limit" fieldPeriodStart = "period_start" fieldPeriodEnd = "period_end" fieldPlanType = "plan_type" diff --git a/internal/interfaces/adapters/nodequotaadapters.go b/internal/interfaces/adapters/nodequotaadapters.go index f15658c..0aae5ac 100644 --- a/internal/interfaces/adapters/nodequotaadapters.go +++ b/internal/interfaces/adapters/nodequotaadapters.go @@ -117,11 +117,14 @@ func (a *NodeSubscriptionQuotaLoaderAdapter) LoadQuotaByID(ctx context.Context, } } - // Build cached quota object + // Cache the *traffic cycle* (resolved via ResolveTrafficPeriod) rather than + // the billing period so the real-time node enforcer queries usage over the + // correct window for calendar_month plans. + cycle := subscription.ResolveTrafficPeriod(plan, sub) cachedQuota := &cache.CachedQuota{ Limit: int64(trafficLimit), - PeriodStart: sub.CurrentPeriodStart(), - PeriodEnd: sub.CurrentPeriodEnd(), + PeriodStart: cycle.Start, + PeriodEnd: cycle.End, PlanType: plan.PlanType().String(), Suspended: false, } diff --git a/internal/shared/logger/interface.go b/internal/shared/logger/interface.go index 4253a4a..61bcce1 100644 --- a/internal/shared/logger/interface.go +++ b/internal/shared/logger/interface.go @@ -1,6 +1,38 @@ package logger -import "log/slog" +import ( + "context" + "errors" + "log/slog" +) + +// isContextCancellation reports whether err is a context cancellation or +// deadline-exceeded error. Such errors usually indicate the caller (HTTP +// client, background job that was cancelled, etc.) went away, not a real +// backend failure, so logging them at ERROR creates alert noise. +func isContextCancellation(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +// hasContextCancellationValue scans args for a non-nil error value that is a +// context cancellation. Both `Errorw("msg", "key", err)` style and `Error("msg", +// slog.Any("err", err))` style are supported by walking every arg and testing +// any error-typed value. +func hasContextCancellationValue(args []any) bool { + for _, a := range args { + switch v := a.(type) { + case error: + if isContextCancellation(v) { + return true + } + case slog.Attr: + if e, ok := v.Value.Any().(error); ok && isContextCancellation(e) { + return true + } + } + } + return false +} type Interface interface { Debug(msg string, args ...any) @@ -47,6 +79,13 @@ func (l *slogLogger) Warn(msg string, args ...any) { } func (l *slogLogger) Error(msg string, args ...any) { + // Demote context-cancellation errors to WARN: they indicate caller + // lifecycle (request canceled, deadline reached) rather than backend + // failure, so they should not page operators. + if hasContextCancellationValue(args) { + l.logger.Warn(msg, args...) + return + } l.logger.Error(msg, args...) } @@ -80,6 +119,11 @@ func (l *slogLogger) Warnw(msg string, keysAndValues ...interface{}) { } func (l *slogLogger) Errorw(msg string, keysAndValues ...interface{}) { + // See Error: context-cancellation errors are not real failures. + if hasContextCancellationValue(keysAndValues) { + l.logger.Warn(msg, keysAndValues...) + return + } l.logger.Error(msg, keysAndValues...) } diff --git a/internal/shared/logger/interface_test.go b/internal/shared/logger/interface_test.go new file mode 100644 index 0000000..ffd4389 --- /dev/null +++ b/internal/shared/logger/interface_test.go @@ -0,0 +1,108 @@ +package logger + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "testing" +) + +// newCapturingLogger builds a slogLogger that writes JSON to buf at LevelDebug +// so we can assert what level a given call landed on. +func newCapturingLogger(buf *bytes.Buffer) *slogLogger { + h := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + return &slogLogger{logger: slog.New(h)} +} + +func levelOf(t *testing.T, line string) string { + t.Helper() + // slog JSON output includes "level":"INFO|WARN|ERROR|DEBUG" + for _, want := range []string{`"level":"DEBUG"`, `"level":"INFO"`, `"level":"WARN"`, `"level":"ERROR"`} { + if strings.Contains(line, want) { + return strings.Trim(strings.SplitN(want, ":", 2)[1], `"`) + } + } + t.Fatalf("no level found in log line: %s", line) + return "" +} + +func TestErrorw_DemotesContextCanceled(t *testing.T) { + var buf bytes.Buffer + log := newCapturingLogger(&buf) + + log.Errorw("query failed", "id", 42, "error", context.Canceled) + + if got := levelOf(t, buf.String()); got != "WARN" { + t.Fatalf("expected WARN for context.Canceled, got %s; line=%s", got, buf.String()) + } +} + +func TestErrorw_DemotesDeadlineExceeded(t *testing.T) { + var buf bytes.Buffer + log := newCapturingLogger(&buf) + + log.Errorw("query failed", "error", context.DeadlineExceeded) + + if got := levelOf(t, buf.String()); got != "WARN" { + t.Fatalf("expected WARN for context.DeadlineExceeded, got %s", got) + } +} + +func TestErrorw_DemotesWrappedCancellation(t *testing.T) { + var buf bytes.Buffer + log := newCapturingLogger(&buf) + + wrapped := fmt.Errorf("db query failed: %w", context.Canceled) + log.Errorw("repo failed", "error", wrapped) + + if got := levelOf(t, buf.String()); got != "WARN" { + t.Fatalf("expected WARN for wrapped cancellation, got %s", got) + } +} + +func TestErrorw_PreservesRealError(t *testing.T) { + var buf bytes.Buffer + log := newCapturingLogger(&buf) + + log.Errorw("real failure", "error", errors.New("connection refused")) + + if got := levelOf(t, buf.String()); got != "ERROR" { + t.Fatalf("expected ERROR for real error, got %s", got) + } +} + +func TestError_DemotesContextCancellationViaSlogAttr(t *testing.T) { + var buf bytes.Buffer + log := newCapturingLogger(&buf) + + log.Error("query failed", slog.Any("err", context.Canceled)) + + if got := levelOf(t, buf.String()); got != "WARN" { + t.Fatalf("expected WARN for slog.Any(context.Canceled), got %s", got) + } +} + +func TestError_PreservesRealErrorWithSlogAttr(t *testing.T) { + var buf bytes.Buffer + log := newCapturingLogger(&buf) + + log.Error("query failed", slog.Any("err", errors.New("boom"))) + + if got := levelOf(t, buf.String()); got != "ERROR" { + t.Fatalf("expected ERROR for real slog.Any error, got %s", got) + } +} + +func TestErrorw_NoCancellationKeepsError(t *testing.T) { + var buf bytes.Buffer + log := newCapturingLogger(&buf) + + log.Errorw("just a message", "id", 7) + + if got := levelOf(t, buf.String()); got != "ERROR" { + t.Fatalf("expected ERROR when no error value present, got %s", got) + } +}