package usecase import ( "context" "fmt" "math" "sort" "time" "apps/backend/internal/module/usage/domain" ) const analyticsDateLayout = "2006-01-02" func ParseAnalyticsQuery(granularity, fromRaw, toRaw string) (domain.AnalyticsQuery, error) { if granularity != "day" && granularity != "month" && granularity != "year" { return domain.AnalyticsQuery{}, fmt.Errorf("%w: granularity must be day, month, or year", domain.ErrInvalidRange) } from, err := time.Parse(analyticsDateLayout, fromRaw) if err != nil { return domain.AnalyticsQuery{}, fmt.Errorf("%w: from must be YYYY-MM-DD", domain.ErrInvalidRange) } to, err := time.Parse(analyticsDateLayout, toRaw) if err != nil { return domain.AnalyticsQuery{}, fmt.Errorf("%w: to must be YYYY-MM-DD", domain.ErrInvalidRange) } if from.After(to) { return domain.AnalyticsQuery{}, fmt.Errorf("%w: from must not be after to", domain.ErrInvalidRange) } q := domain.AnalyticsQuery{Granularity: granularity, From: from.UTC(), To: to.UTC()} if len(analyticsBuckets(q)) > map[string]int{"day": 93, "month": 36, "year": 8}[granularity] { return domain.AnalyticsQuery{}, fmt.Errorf("%w: range exceeds %s limit", domain.ErrInvalidRange, granularity) } return q, nil } type analyticsBucketRange struct { key string label string from time.Time to time.Time } func analyticsBuckets(q domain.AnalyticsQuery) []analyticsBucketRange { var buckets []analyticsBucketRange switch q.Granularity { case "day": for cur := q.From; !cur.After(q.To); cur = cur.AddDate(0, 0, 1) { buckets = append(buckets, analyticsBucketRange{key: cur.Format(analyticsDateLayout), label: cur.Format("01/02"), from: cur, to: cur}) } case "month": for cur := time.Date(q.From.Year(), q.From.Month(), 1, 0, 0, 0, 0, time.UTC); !cur.After(q.To); cur = cur.AddDate(0, 1, 0) { end := cur.AddDate(0, 1, -1) buckets = append(buckets, analyticsBucketRange{key: cur.Format("2006-01"), label: cur.Format("2006/01"), from: maxTime(cur, q.From), to: minTime(end, q.To)}) } case "year": for year := q.From.Year(); year <= q.To.Year(); year++ { start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) end := time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC) key := fmt.Sprintf("%04d", year) buckets = append(buckets, analyticsBucketRange{key: key, label: key, from: maxTime(start, q.From), to: minTime(end, q.To)}) } } return buckets } func maxTime(a, b time.Time) time.Time { if a.After(b) { return a } return b } func minTime(a, b time.Time) time.Time { if a.Before(b) { return a } return b } func proratedCredits(monthly int, from, to time.Time) int { credits := 0.0 for cur := from; !cur.After(to); cur = cur.AddDate(0, 0, 1) { days := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() credits += float64(monthly) / float64(days) } return int(math.Round(credits)) } func isAIMeter(meter string) bool { return meter == domain.MeterAICopy || meter == domain.MeterAIResearch || meter == domain.MeterAIImage } func emptyMeterCounts() map[string]domain.MeterCount { return map[string]domain.MeterCount{ domain.MeterAICopy: {}, domain.MeterAIResearch: {}, domain.MeterWebSearch: {}, domain.MeterAIImage: {}, } } func (s *Service) TenantAnalytics(ctx context.Context, q domain.AnalyticsQuery, tenantMembers []domain.AnalyticsMember) (*domain.TenantAnalytics, error) { events, err := s.Repo.ListEventsRange(ctx, q.From.UnixNano(), q.To.AddDate(0, 0, 1).UnixNano()) if err != nil { return nil, err } type memberAcc struct { row domain.AnalyticsMemberRow } members := make(map[int64]*memberAcc, len(tenantMembers)) for _, member := range tenantMembers { members[member.UID] = &memberAcc{row: domain.AnalyticsMemberRow{UID: member.UID, Email: member.Email, DisplayName: member.DisplayName}} } for _, event := range events { if members[event.UID] == nil { members[event.UID] = &memberAcc{row: domain.AnalyticsMemberRow{UID: event.UID}} } } buckets := analyticsBuckets(q) bucketByKey := make(map[string]int, len(buckets)) result := &domain.TenantAnalytics{ Granularity: q.Granularity, From: q.From.Format(analyticsDateLayout), To: q.To.Format(analyticsDateLayout), RangeLabel: fmt.Sprintf("%s - %s", q.From.Format(analyticsDateLayout), q.To.Format(analyticsDateLayout)), ByMeter: emptyMeterCounts(), Byok: domain.ByokBlock{ByMeter: emptyMeterCounts()}, } for _, bucket := range buckets { row := domain.AnalyticsBucket{Key: bucket.key, Label: bucket.label} result.Series = append(result.Series, row) bucketByKey[bucket.key] = len(result.Series) - 1 } for _, acc := range members { prefs, prefsErr := s.ensurePrefs(ctx, acc.row.UID) if prefsErr != nil { return nil, prefsErr } plan := domain.ResolvePlan(prefs.PlanID) acc.row.PlanID, acc.row.Unlimited = plan.ID, prefs.Unlimited acc.row.PurchasedCredits = proratedCredits(plan.MonthlyCredits, q.From, q.To) result.PurchasedCredits += acc.row.PurchasedCredits for i, bucket := range buckets { result.Series[i].PurchasedCredits += proratedCredits(plan.MonthlyCredits, bucket.from, bucket.to) } } for _, event := range events { bucketIndex, hasBucket := bucketByKey[eventBucketKey(event.CreatedAt, q.Granularity)] acc := members[event.UID] if event.KeyMode == domain.KeyModeByok { result.Byok.CallCount++ meter := result.Byok.ByMeter[event.Meter] meter.Count++ result.Byok.ByMeter[event.Meter] = meter acc.row.ByokCallCount++ if hasBucket { result.Series[bucketIndex].ByokCallCount++ } continue } if event.KeyMode != domain.KeyModePlatform { continue } credits := event.Credits if credits < 0 { credits = 0 } result.ConsumedCredits += credits meter := result.ByMeter[event.Meter] meter.Count++ meter.Credits += credits result.ByMeter[event.Meter] = meter acc.row.TotalCredits += credits if isAIMeter(event.Meter) { result.AICalls++ acc.row.AICalls++ if hasBucket { result.Series[bucketIndex].AICalls++ } } else if event.Meter == domain.MeterWebSearch { result.SearchCalls++ acc.row.SearchCalls++ if hasBucket { result.Series[bucketIndex].SearchCalls++ } } if hasBucket { result.Series[bucketIndex].ConsumedCredits += credits } } result.RemainingCredits = maxInt(0, result.PurchasedCredits-result.ConsumedCredits) result.Pct = percentage(result.ConsumedCredits, result.PurchasedCredits) for _, acc := range members { acc.row.RemainingCredits = maxInt(0, acc.row.PurchasedCredits-acc.row.TotalCredits) acc.row.Pct = percentage(acc.row.TotalCredits, acc.row.PurchasedCredits) result.Members = append(result.Members, acc.row) } sort.Slice(result.Members, func(i, j int) bool { if result.Members[i].TotalCredits == result.Members[j].TotalCredits { return result.Members[i].UID < result.Members[j].UID } return result.Members[i].TotalCredits > result.Members[j].TotalCredits }) return result, nil } func eventBucketKey(nano int64, granularity string) string { t := time.Unix(0, nano).UTC() switch granularity { case "day": return t.Format(analyticsDateLayout) case "month": return t.Format("2006-01") default: return t.Format("2006") } } func percentage(used, total int) int { if total <= 0 { return 0 } return minInt(999, int(math.Round(float64(used)*100/float64(total)))) } func minInt(a, b int) int { if a < b { return a } return b } func maxInt(a, b int) int { if a > b { return a } return b }