package billing import ( "context" "errors" "sort" "time" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) const maxUsageOverviewRange = 366 * 24 * time.Hour type UsageOverviewScope struct { TenantID string `json:"tenant_id"` TenantName string `json:"tenant_name"` HouseholdID string `json:"household_id,omitempty"` HouseholdName string `json:"household_name,omitempty"` DeviceID string `json:"device_id,omitempty"` DeviceName string `json:"device_name,omitempty"` } type UsageOverviewDay struct { Date string `json:"date"` DurationMS int64 `json:"duration_ms"` ConversationCount int `json:"conversation_count"` CostMicros int64 `json:"cost_micros"` } type UsageOverviewDevice struct { ID string `json:"id"` Name string `json:"name"` HouseholdID string `json:"household_id"` HouseholdName string `json:"household_name"` DurationMS int64 `json:"duration_ms"` ConversationCount int `json:"conversation_count"` CostMicros int64 `json:"cost_micros"` } type UsageOverviewConversation struct { ID string `json:"id"` HouseholdID string `json:"household_id"` HouseholdName string `json:"household_name"` DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` SubjectName string `json:"subject_name"` Status string `json:"status"` StartedAt time.Time `json:"started_at"` EndedAt *time.Time `json:"ended_at,omitempty"` DurationMS int64 `json:"duration_ms"` CostMicros int64 `json:"cost_micros"` } type UsageOverview struct { From time.Time `json:"from"` To time.Time `json:"to"` Timezone string `json:"timezone"` Scope UsageOverviewScope `json:"scope"` TotalDurationMS int64 `json:"total_duration_ms"` TodayDurationMS int64 `json:"today_duration_ms"` ConversationCount int `json:"conversation_count"` AverageDurationMS int64 `json:"average_duration_ms"` ActiveDays int `json:"active_days"` CurrentStreakDays int `json:"current_streak_days"` LongestStreakDays int `json:"longest_streak_days"` CostMicros int64 `json:"cost_micros"` Currency string `json:"currency"` CostAvailable bool `json:"cost_available"` Daily []UsageOverviewDay `json:"daily"` Devices []UsageOverviewDevice `json:"devices"` RecentConversations []UsageOverviewConversation `json:"recent_conversations"` } type usageOverviewTarget struct { tenantID, tenantPublicID, tenantName string householdID, householdPublicID, householdName string deviceID, devicePublicID, deviceName string } type usageOverviewRow struct { conversationID, householdID, householdName string deviceID, deviceName, subjectName, status string startedAt time.Time endedAt *time.Time costMicros int64 currency string hasCost bool } func (s *Service) FamilyUsageOverview(ctx context.Context, principal identity.Principal, householdPublicID, devicePublicID, timezone string, from, to time.Time) (UsageOverview, error) { if _, err := uuid.Parse(householdPublicID); err != nil { return UsageOverview{}, ErrInvalid } target := usageOverviewTarget{} var role identity.Role err := s.pool.QueryRow(ctx, ` SELECT t.id::text,t.public_id::text,t.name,h.id::text,h.public_id::text,h.name,hm.role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=ua.tenant_id AND hm.status='active' JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id AND h.status<>'deleted' WHERE h.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active'`, householdPublicID, principal.Issuer, principal.Subject).Scan( &target.tenantID, &target.tenantPublicID, &target.tenantName, &target.householdID, &target.householdPublicID, &target.householdName, &role) if errors.Is(err, pgx.ErrNoRows) || (err == nil && !identity.Can(role, identity.CapabilityHouseholdRead)) { return UsageOverview{}, ErrAccessDenied } if err != nil { return UsageOverview{}, err } if err = s.resolveUsageOverviewDevice(ctx, &target, devicePublicID, true); err != nil { return UsageOverview{}, err } return s.buildUsageOverview(ctx, target, timezone, from, to) } func (s *Service) OpsUsageOverview(ctx context.Context, principal identity.Principal, tenantPublicID, householdPublicID, devicePublicID, timezone string, from, to time.Time) (UsageOverview, error) { currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin") if err != nil { return UsageOverview{}, err } target := usageOverviewTarget{tenantID: currentActor.tenantID, tenantPublicID: currentActor.tenantPublicID} if err = s.pool.QueryRow(ctx, `SELECT name FROM tenant WHERE id=$1::uuid`, target.tenantID).Scan(&target.tenantName); err != nil { return UsageOverview{}, err } if householdPublicID != "" { if _, parseErr := uuid.Parse(householdPublicID); parseErr != nil { return UsageOverview{}, ErrInvalid } err = s.pool.QueryRow(ctx, `SELECT id::text,public_id::text,name FROM household WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status<>'deleted'`, target.tenantID, householdPublicID).Scan(&target.householdID, &target.householdPublicID, &target.householdName) if errors.Is(err, pgx.ErrNoRows) { return UsageOverview{}, ErrNotFound } if err != nil { return UsageOverview{}, err } } if err = s.resolveUsageOverviewDevice(ctx, &target, devicePublicID, false); err != nil { return UsageOverview{}, err } return s.buildUsageOverview(ctx, target, timezone, from, to) } func (s *Service) resolveUsageOverviewDevice(ctx context.Context, target *usageOverviewTarget, devicePublicID string, family bool) error { if devicePublicID == "" { return nil } if _, err := uuid.Parse(devicePublicID); err != nil { return ErrInvalid } query := `SELECT d.id::text,d.public_id::text,d.model FROM device d WHERE d.tenant_id=$1::uuid AND d.public_id=$2::uuid` args := []any{target.tenantID, devicePublicID} if target.householdID != "" { query += ` AND ( EXISTS(SELECT 1 FROM device_binding b WHERE b.tenant_id=d.tenant_id AND b.device_id=d.id AND b.household_id=$3::uuid AND b.unbound_at IS NULL) OR EXISTS(SELECT 1 FROM conversation c WHERE c.tenant_id=d.tenant_id AND c.device_id=d.id AND c.household_id=$3::uuid) )` args = append(args, target.householdID) } err := s.pool.QueryRow(ctx, query, args...).Scan(&target.deviceID, &target.devicePublicID, &target.deviceName) if errors.Is(err, pgx.ErrNoRows) { if family { return ErrAccessDenied } return ErrNotFound } return err } func (s *Service) buildUsageOverview(ctx context.Context, target usageOverviewTarget, timezone string, from, to time.Time) (UsageOverview, error) { location, err := validateUsageOverviewRange(timezone, from, to) if err != nil { return UsageOverview{}, err } from = from.UTC() to = to.UTC() rows, err := s.pool.Query(ctx, ` SELECT c.public_id::text,h.public_id::text,h.name,d.public_id::text,d.model,sp.preferred_name,c.status,c.started_at,c.ended_at, COALESCE(cost.cost_micros,0)::bigint,COALESCE(cost.currency,''),COALESCE(cost.has_cost,false) FROM conversation c JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id LEFT JOIN LATERAL ( SELECT SUM(ccl.cost_micros)::bigint AS cost_micros,MIN(ccl.currency) AS currency,COUNT(*)>0 AS has_cost FROM conversation_cost_line ccl WHERE ccl.conversation_id=c.id ) cost ON true WHERE c.tenant_id=$1::uuid AND c.started_at IS NOT NULL AND c.started_at>=$2 AND c.started_at<$3 AND ($4::uuid IS NULL OR c.household_id=$4::uuid) AND ($5::uuid IS NULL OR c.device_id=$5::uuid) ORDER BY c.started_at DESC,c.public_id`, target.tenantID, from, to, nullableUUID(target.householdID), nullableUUID(target.deviceID)) if err != nil { return UsageOverview{}, err } defer rows.Close() result := UsageOverview{ From: from, To: to, Timezone: timezone, Scope: UsageOverviewScope{TenantID: target.tenantPublicID, TenantName: target.tenantName, HouseholdID: target.householdPublicID, HouseholdName: target.householdName, DeviceID: target.devicePublicID, DeviceName: target.deviceName}, Daily: []UsageOverviewDay{}, Devices: []UsageOverviewDevice{}, RecentConversations: []UsageOverviewConversation{}, } daily := map[string]*UsageOverviewDay{} devices := map[string]*UsageOverviewDevice{} activeDates := map[string]struct{}{} currencies := map[string]struct{}{} today := time.Now().In(location).Format("2006-01-02") for rows.Next() { var row usageOverviewRow if err = rows.Scan(&row.conversationID, &row.householdID, &row.householdName, &row.deviceID, &row.deviceName, &row.subjectName, &row.status, &row.startedAt, &row.endedAt, &row.costMicros, &row.currency, &row.hasCost); err != nil { return UsageOverview{}, err } duration := conversationDurationMS(row.startedAt, row.endedAt, to) date := row.startedAt.In(location).Format("2006-01-02") day := daily[date] if day == nil { day = &UsageOverviewDay{Date: date} daily[date] = day } day.DurationMS += duration day.ConversationCount++ day.CostMicros += row.costMicros activeDates[date] = struct{}{} device := devices[row.deviceID] if device == nil { device = &UsageOverviewDevice{ID: row.deviceID, Name: row.deviceName, HouseholdID: row.householdID, HouseholdName: row.householdName} devices[row.deviceID] = device } device.DurationMS += duration device.ConversationCount++ device.CostMicros += row.costMicros result.TotalDurationMS += duration result.ConversationCount++ result.CostMicros += row.costMicros if date == today { result.TodayDurationMS += duration } if row.hasCost { result.CostAvailable = true currencies[row.currency] = struct{}{} } if len(result.RecentConversations) < 20 { result.RecentConversations = append(result.RecentConversations, UsageOverviewConversation{ ID: row.conversationID, HouseholdID: row.householdID, HouseholdName: row.householdName, DeviceID: row.deviceID, DeviceName: row.deviceName, SubjectName: row.subjectName, Status: row.status, StartedAt: row.startedAt, EndedAt: row.endedAt, DurationMS: duration, CostMicros: row.costMicros, }) } } if err = rows.Err(); err != nil { return UsageOverview{}, err } if len(currencies) > 1 { return UsageOverview{}, errors.New("usage overview contains multiple currencies") } for currency := range currencies { result.Currency = currency } if result.ConversationCount > 0 { result.AverageDurationMS = result.TotalDurationMS / int64(result.ConversationCount) } for _, day := range daily { result.Daily = append(result.Daily, *day) } sort.Slice(result.Daily, func(i, j int) bool { return result.Daily[i].Date < result.Daily[j].Date }) for _, device := range devices { result.Devices = append(result.Devices, *device) } sort.Slice(result.Devices, func(i, j int) bool { if result.Devices[i].DurationMS == result.Devices[j].DurationMS { return result.Devices[i].ID < result.Devices[j].ID } return result.Devices[i].DurationMS > result.Devices[j].DurationMS }) result.ActiveDays = len(activeDates) result.CurrentStreakDays, result.LongestStreakDays = usageStreaks(activeDates, time.Now().In(location)) return result, nil } func validateUsageOverviewRange(timezone string, from, to time.Time) (*time.Location, error) { if timezone == "" || from.IsZero() || !to.After(from) || to.Sub(from) > maxUsageOverviewRange || to.After(time.Now().UTC().Add(5*time.Minute)) { return nil, ErrInvalid } location, err := time.LoadLocation(timezone) if err != nil { return nil, ErrInvalid } return location, nil } func conversationDurationMS(startedAt time.Time, endedAt *time.Time, rangeEnd time.Time) int64 { end := rangeEnd if endedAt != nil && endedAt.Before(end) { end = *endedAt } if end.Before(startedAt) { return 0 } return end.Sub(startedAt).Milliseconds() } func usageStreaks(active map[string]struct{}, now time.Time) (current, longest int) { if len(active) == 0 { return 0, 0 } dates := make([]time.Time, 0, len(active)) for value := range active { date, err := time.ParseInLocation("2006-01-02", value, now.Location()) if err == nil { dates = append(dates, date) } } sort.Slice(dates, func(i, j int) bool { return dates[i].Before(dates[j]) }) streak := 0 for index, date := range dates { if index == 0 || date.Equal(dates[index-1].AddDate(0, 0, 1)) { streak++ } else { streak = 1 } if streak > longest { longest = streak } } latest := dates[len(dates)-1] today, _ := time.ParseInLocation("2006-01-02", now.Format("2006-01-02"), now.Location()) if latest.Before(today.AddDate(0, 0, -1)) { return 0, longest } current = 1 for index := len(dates) - 1; index > 0 && dates[index].Equal(dates[index-1].AddDate(0, 0, 1)); index-- { current++ } return current, longest } func nullableUUID(value string) any { if value == "" { return nil } return value }