Files
story/internal/billing/overview.go
T
2026-08-29 21:56:51 +08:00

348 lines
14 KiB
Go

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
}