package admin import ( "context" "fmt" "time" "github.com/ultisuite/ulti-backend/internal/ai/cost" ) type AIUsageSummary struct { CostTodayMicroEUR int64 `json:"cost_today_micro_eur"` CostMonthMicroEUR int64 `json:"cost_month_micro_eur"` CostTodayEUR float64 `json:"cost_today_eur"` CostMonthEUR float64 `json:"cost_month_eur"` Currency string `json:"currency"` DailySeries []AIUsageDayPoint `json:"daily_series"` TopUsers []AIUsageTopUser `json:"top_users"` TopModels []AIUsageTopModel `json:"top_models"` OrgPolicy cost.EffectiveLimits `json:"org_policy"` } type AIUsageDayPoint struct { Date string `json:"date"` CostOrgMicroEUR int64 `json:"cost_org_micro_eur"` CostUserMicroEUR int64 `json:"cost_user_micro_eur"` CostOrgEUR float64 `json:"cost_org_eur"` Requests int `json:"requests"` } type AIUsageTopUser struct { UserID string `json:"user_id"` Email string `json:"email"` DisplayName string `json:"display_name"` CostOrgMicroEUR int64 `json:"cost_org_micro_eur"` CostOrgEUR float64 `json:"cost_org_eur"` } type AIUsageTopModel struct { ModelID string `json:"model_id"` CostMicroEUR int64 `json:"cost_micro_eur"` CostEUR float64 `json:"cost_eur"` RequestCount int `json:"request_count"` } type AIUserUsageDetail struct { UserID string `json:"user_id"` Email string `json:"email"` DisplayName string `json:"display_name"` Summary AIUsageSummary `json:"summary"` Events []AIUsageEventItem `json:"events"` EventsTotal int `json:"events_total"` } type AIUsageEventItem struct { ID string `json:"id"` CreatedAt string `json:"created_at"` Feature string `json:"feature"` ModelID string `json:"model_id"` ProviderID string `json:"provider_id"` BillingScope string `json:"billing_scope"` CostMicroEUR int64 `json:"cost_micro_eur"` CostEUR float64 `json:"cost_eur"` PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` CachedInputTokens int `json:"cached_input_tokens"` Estimated bool `json:"estimated"` } type AIPricingEntry struct { ModelID string `json:"model_id"` ProviderType string `json:"provider_type"` InputMicroEURPerMTok int64 `json:"input_micro_eur_per_mtok"` CachedInputMicroEURPerMTok int64 `json:"cached_input_micro_eur_per_mtok,omitempty"` OutputMicroEURPerMTok int64 `json:"output_micro_eur_per_mtok"` ReasoningMicroEURPerMTok int64 `json:"reasoning_micro_eur_per_mtok,omitempty"` InputEURPerMTok float64 `json:"input_eur_per_mtok"` OutputEURPerMTok float64 `json:"output_eur_per_mtok"` } type AICostPolicyEntry struct { ScopeType string `json:"scope_type"` ScopeID *string `json:"scope_id,omitempty"` ScopeName string `json:"scope_name,omitempty"` DailyLimitMicroEUR *int64 `json:"daily_limit_micro_eur,omitempty"` MonthlyLimitMicroEUR *int64 `json:"monthly_limit_micro_eur,omitempty"` DailyLimitEUR *float64 `json:"daily_limit_eur,omitempty"` MonthlyLimitEUR *float64 `json:"monthly_limit_eur,omitempty"` WarnThresholdPct int `json:"warn_threshold_pct"` } type putAIPricingRequest struct { Prices []AIPricingEntry `json:"prices"` } type putAICostPolicyRequest struct { DailyLimitEUR *float64 `json:"daily_limit_eur"` MonthlyLimitEUR *float64 `json:"monthly_limit_eur"` WarnThresholdPct int `json:"warn_threshold_pct"` DailyLimitMicroEUR *int64 `json:"daily_limit_micro_eur,omitempty"` MonthlyLimitMicroEUR *int64 `json:"monthly_limit_micro_eur,omitempty"` } func (s *Service) GetAIUsageSummary(ctx context.Context, billingScope string) (AIUsageSummary, error) { today := time.Now().UTC().Truncate(24 * time.Hour) month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) out := AIUsageSummary{ Currency: "EUR", DailySeries: []AIUsageDayPoint{}, TopUsers: []AIUsageTopUser{}, TopModels: []AIUsageTopModel{}, } policySvc := cost.NewPolicyService(s.db) orgPolicy, _ := policySvc.GetOrgPolicy(ctx) out.OrgPolicy = orgPolicy var dailyOrg, dailyUser int64 _ = s.db.QueryRow(ctx, ` SELECT COALESCE(cost_micro_eur_org, 0), COALESCE(cost_micro_eur_user, 0) FROM ai_org_usage_daily WHERE usage_date = $1 `, today).Scan(&dailyOrg, &dailyUser) var monthlyOrg int64 _ = s.db.QueryRow(ctx, ` SELECT COALESCE(cost_micro_eur_org, 0) FROM ai_org_usage_monthly WHERE usage_month = $1 `, month).Scan(&monthlyOrg) if billingScope == "user" { out.CostTodayMicroEUR = dailyUser } else { out.CostTodayMicroEUR = dailyOrg } out.CostMonthMicroEUR = monthlyOrg out.CostTodayEUR = cost.MicroEURToEUR(out.CostTodayMicroEUR) out.CostMonthEUR = cost.MicroEURToEUR(out.CostMonthMicroEUR) rows, err := s.db.Query(ctx, ` SELECT usage_date, cost_micro_eur_org, cost_micro_eur_user, requests FROM ai_org_usage_daily WHERE usage_date >= $1 ORDER BY usage_date ASC `, today.AddDate(0, 0, -29)) if err == nil { defer rows.Close() for rows.Next() { var pt AIUsageDayPoint var d time.Time if err := rows.Scan(&d, &pt.CostOrgMicroEUR, &pt.CostUserMicroEUR, &pt.Requests); err != nil { continue } pt.Date = d.Format("2006-01-02") pt.CostOrgEUR = cost.MicroEURToEUR(pt.CostOrgMicroEUR) out.DailySeries = append(out.DailySeries, pt) } } topUserRows, err := s.db.Query(ctx, ` SELECT u.id::text, COALESCE(u.email, ''), COALESCE(u.display_name, ''), COALESCE(SUM(e.cost_micro_eur), 0) FROM ai_usage_events e JOIN users u ON u.id = e.user_id WHERE e.created_at >= $1 AND e.billing_scope = 'org' GROUP BY u.id, u.email, u.display_name ORDER BY SUM(e.cost_micro_eur) DESC LIMIT 10 `, month) if err == nil { defer topUserRows.Close() for topUserRows.Next() { var u AIUsageTopUser if err := topUserRows.Scan(&u.UserID, &u.Email, &u.DisplayName, &u.CostOrgMicroEUR); err != nil { continue } u.CostOrgEUR = cost.MicroEURToEUR(u.CostOrgMicroEUR) out.TopUsers = append(out.TopUsers, u) } } topModelRows, err := s.db.Query(ctx, ` SELECT model_id, COALESCE(SUM(cost_micro_eur), 0), COUNT(*) FROM ai_usage_events WHERE created_at >= $1 AND billing_scope = 'org' GROUP BY model_id ORDER BY SUM(cost_micro_eur) DESC LIMIT 10 `, month) if err == nil { defer topModelRows.Close() for topModelRows.Next() { var m AIUsageTopModel if err := topModelRows.Scan(&m.ModelID, &m.CostMicroEUR, &m.RequestCount); err != nil { continue } m.CostEUR = cost.MicroEURToEUR(m.CostMicroEUR) out.TopModels = append(out.TopModels, m) } } return out, nil } func (s *Service) GetUserAIUsage(ctx context.Context, userID string, limit, offset int) (AIUserUsageDetail, error) { if limit <= 0 { limit = 50 } var detail AIUserUsageDetail detail.Events = []AIUsageEventItem{} err := s.db.QueryRow(ctx, ` SELECT id::text, COALESCE(email, ''), COALESCE(display_name, '') FROM users WHERE id = $1::uuid `, userID).Scan(&detail.UserID, &detail.Email, &detail.DisplayName) if err != nil { return detail, fmt.Errorf("user not found") } summary, _ := s.GetAIUsageSummary(ctx, "org") detail.Summary = summary _ = s.db.QueryRow(ctx, ` SELECT COUNT(*) FROM ai_usage_events WHERE user_id = $1::uuid `, userID).Scan(&detail.EventsTotal) rows, err := s.db.Query(ctx, ` SELECT id::text, created_at, feature, model_id, provider_id, billing_scope, cost_micro_eur, prompt_tokens, completion_tokens, cached_input_tokens, estimated FROM ai_usage_events WHERE user_id = $1::uuid ORDER BY created_at DESC LIMIT $2 OFFSET $3 `, userID, limit, offset) if err != nil { return detail, err } defer rows.Close() for rows.Next() { var ev AIUsageEventItem var created time.Time if err := rows.Scan(&ev.ID, &created, &ev.Feature, &ev.ModelID, &ev.ProviderID, &ev.BillingScope, &ev.CostMicroEUR, &ev.PromptTokens, &ev.CompletionTokens, &ev.CachedInputTokens, &ev.Estimated); err != nil { continue } ev.CreatedAt = created.UTC().Format(time.RFC3339) ev.CostEUR = cost.MicroEURToEUR(ev.CostMicroEUR) detail.Events = append(detail.Events, ev) } return detail, rows.Err() } func (s *Service) ListAIPricing(ctx context.Context) ([]AIPricingEntry, error) { store := cost.NewPricingStore(s.db) prices, err := store.ListPrices(ctx) if err != nil { return []AIPricingEntry{}, err } out := make([]AIPricingEntry, 0, len(prices)) for _, p := range prices { out = append(out, AIPricingEntry{ ModelID: p.ModelID, ProviderType: p.ProviderType, InputMicroEURPerMTok: p.InputMicroEURPerMTok, CachedInputMicroEURPerMTok: p.CachedInputMicroEURPerMTok, OutputMicroEURPerMTok: p.OutputMicroEURPerMTok, ReasoningMicroEURPerMTok: p.ReasoningMicroEURPerMTok, InputEURPerMTok: cost.MicroEURToEUR(p.InputMicroEURPerMTok), OutputEURPerMTok: cost.MicroEURToEUR(p.OutputMicroEURPerMTok), }) } return out, nil } func (s *Service) PutAIPricing(ctx context.Context, actorSub string, req putAIPricingRequest) ([]AIPricingEntry, error) { store := cost.NewPricingStore(s.db) for _, p := range req.Prices { if err := store.UpsertModelPrice(ctx, cost.ModelPrice{ ModelID: p.ModelID, ProviderType: p.ProviderType, InputMicroEURPerMTok: p.InputMicroEURPerMTok, CachedInputMicroEURPerMTok: p.CachedInputMicroEURPerMTok, OutputMicroEURPerMTok: p.OutputMicroEURPerMTok, ReasoningMicroEURPerMTok: p.ReasoningMicroEURPerMTok, }); err != nil { return nil, err } } s.logAudit(ctx, actorSub, "update_ai_pricing", map[string]any{"count": len(req.Prices)}) return s.ListAIPricing(ctx) } func (s *Service) ListAICostPolicies(ctx context.Context) ([]AICostPolicyEntry, error) { rows, err := s.db.Query(ctx, ` SELECT p.scope_type, p.scope_id::text, p.daily_limit_micro_eur, p.monthly_limit_micro_eur, p.warn_threshold_pct, COALESCE(g.name, u.email, 'Organisation') FROM ai_cost_policies p LEFT JOIN user_groups g ON p.scope_type = 'group' AND g.id = p.scope_id LEFT JOIN users u ON p.scope_type = 'user' AND u.id = p.scope_id ORDER BY p.priority ASC, p.scope_type `) if err != nil { return []AICostPolicyEntry{}, err } defer rows.Close() var out = make([]AICostPolicyEntry, 0) for rows.Next() { var e AICostPolicyEntry var scopeID *string if err := rows.Scan(&e.ScopeType, &scopeID, &e.DailyLimitMicroEUR, &e.MonthlyLimitMicroEUR, &e.WarnThresholdPct, &e.ScopeName); err != nil { continue } e.ScopeID = scopeID if e.DailyLimitMicroEUR != nil { v := cost.MicroEURToEUR(*e.DailyLimitMicroEUR) e.DailyLimitEUR = &v } if e.MonthlyLimitMicroEUR != nil { v := cost.MicroEURToEUR(*e.MonthlyLimitMicroEUR) e.MonthlyLimitEUR = &v } out = append(out, e) } return out, rows.Err() } func (s *Service) PutUserAICostPolicy(ctx context.Context, actorSub, userID string, req putAICostPolicyRequest) error { daily, monthly := resolveCostLimits(req) policy := cost.NewPolicyService(s.db) if err := policy.UpsertScopePolicy(ctx, "user", userID, daily, monthly, req.WarnThresholdPct); err != nil { return err } s.logAudit(ctx, actorSub, "set_user_ai_cost_policy", map[string]any{"user_id": userID}) return nil } func (s *Service) PutGroupAICostPolicy(ctx context.Context, actorSub, groupID string, req putAICostPolicyRequest) error { daily, monthly := resolveCostLimits(req) policy := cost.NewPolicyService(s.db) if err := policy.UpsertScopePolicy(ctx, "group", groupID, daily, monthly, req.WarnThresholdPct); err != nil { return err } s.logAudit(ctx, actorSub, "set_group_ai_cost_policy", map[string]any{"group_id": groupID}) return nil } func (s *Service) syncUsageQuotasToCostPolicy(ctx context.Context, usageQuotas map[string]any) error { if usageQuotas == nil { return nil } var daily, monthly *int64 warnPct := 80 if v, ok := usageQuotas["llm_daily_cost_limit_eur"].(float64); ok && v > 0 { m := int64(v * 1_000_000) daily = &m } if v, ok := usageQuotas["llm_monthly_cost_limit_eur"].(float64); ok && v > 0 { m := int64(v * 1_000_000) monthly = &m } if v, ok := usageQuotas["llm_cost_warn_threshold_pct"].(float64); ok && v > 0 { warnPct = int(v) } policy := cost.NewPolicyService(s.db) return policy.UpsertOrgPolicy(ctx, daily, monthly, warnPct) } func resolveCostLimits(req putAICostPolicyRequest) (*int64, *int64) { var daily, monthly *int64 if req.DailyLimitMicroEUR != nil { daily = req.DailyLimitMicroEUR } else if req.DailyLimitEUR != nil && *req.DailyLimitEUR > 0 { v := int64(*req.DailyLimitEUR * 1_000_000) daily = &v } if req.MonthlyLimitMicroEUR != nil { monthly = req.MonthlyLimitMicroEUR } else if req.MonthlyLimitEUR != nil && *req.MonthlyLimitEUR > 0 { v := int64(*req.MonthlyLimitEUR * 1_000_000) monthly = &v } return daily, monthly }