package cost import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) const ( BillingScopeOrg = "org" BillingScopeUser = "user" ) type RecordInput struct { ExternalUserID string Feature string ModelID string ProviderID string BillingScope string ProviderKeyFingerprint string Usage UsageDetail RequestID string } type Meter struct { db *pgxpool.Pool pricing *PricingStore } func NewMeter(db *pgxpool.Pool) *Meter { return &Meter{db: db, pricing: NewPricingStore(db)} } func (m *Meter) RecordUsage(ctx context.Context, in RecordInput) error { if m.db == nil { return nil } userID, err := m.resolveUserID(ctx, in.ExternalUserID) if err != nil { return err } price, found := m.pricing.LookupPrice(ctx, in.ModelID) cost, estimated := ComputeCostMicroEUR(in.Usage, price, found) tx, err := m.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) _, err = tx.Exec(ctx, ` INSERT INTO ai_usage_events ( user_id, feature, model_id, provider_id, billing_scope, provider_key_fingerprint, prompt_tokens, completion_tokens, cached_input_tokens, reasoning_tokens, cost_micro_eur, estimated, request_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) `, userID, in.Feature, in.ModelID, in.ProviderID, in.BillingScope, in.ProviderKeyFingerprint, in.Usage.PromptTokens, in.Usage.CompletionTokens, in.Usage.CachedInputTokens, in.Usage.ReasoningTokens, cost, estimated, nullStr(in.RequestID)) if err != nil { return err } today := time.Now().UTC().Truncate(24 * time.Hour) month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) tokens := int64(in.Usage.TotalTokens) orgCost := int64(0) userCost := int64(0) if in.BillingScope == BillingScopeOrg { orgCost = cost } else { userCost = cost } _, err = tx.Exec(ctx, ` INSERT INTO ai_usage_daily (user_id, usage_date, requests, tokens, cost_micro_eur_org, cost_micro_eur_user) VALUES ($1, $2, 1, $3, $4, $5) ON CONFLICT (user_id, usage_date) DO UPDATE SET requests = ai_usage_daily.requests + 1, tokens = ai_usage_daily.tokens + EXCLUDED.tokens, cost_micro_eur_org = ai_usage_daily.cost_micro_eur_org + EXCLUDED.cost_micro_eur_org, cost_micro_eur_user = ai_usage_daily.cost_micro_eur_user + EXCLUDED.cost_micro_eur_user `, userID, today, tokens, orgCost, userCost) if err != nil { return err } _, err = tx.Exec(ctx, ` INSERT INTO ai_usage_monthly (user_id, usage_month, tokens, cost_micro_eur_org, cost_micro_eur_user) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, usage_month) DO UPDATE SET tokens = ai_usage_monthly.tokens + EXCLUDED.tokens, cost_micro_eur_org = ai_usage_monthly.cost_micro_eur_org + EXCLUDED.cost_micro_eur_org, cost_micro_eur_user = ai_usage_monthly.cost_micro_eur_user + EXCLUDED.cost_micro_eur_user `, userID, month, tokens, orgCost, userCost) if err != nil { return err } _, err = tx.Exec(ctx, ` INSERT INTO ai_org_usage_daily (usage_date, cost_micro_eur_org, cost_micro_eur_user, requests) VALUES ($1, $2, $3, 1) ON CONFLICT (usage_date) DO UPDATE SET cost_micro_eur_org = ai_org_usage_daily.cost_micro_eur_org + EXCLUDED.cost_micro_eur_org, cost_micro_eur_user = ai_org_usage_daily.cost_micro_eur_user + EXCLUDED.cost_micro_eur_user, requests = ai_org_usage_daily.requests + 1 `, today, orgCost, userCost) if err != nil { return err } _, err = tx.Exec(ctx, ` INSERT INTO ai_org_usage_monthly (usage_month, cost_micro_eur_org, cost_micro_eur_user) VALUES ($1, $2, $3) ON CONFLICT (usage_month) DO UPDATE SET cost_micro_eur_org = ai_org_usage_monthly.cost_micro_eur_org + EXCLUDED.cost_micro_eur_org, cost_micro_eur_user = ai_org_usage_monthly.cost_micro_eur_user + EXCLUDED.cost_micro_eur_user `, month, orgCost, userCost) if err != nil { return err } return tx.Commit(ctx) } func (m *Meter) resolveUserID(ctx context.Context, externalUserID string) (string, error) { var userID string err := m.db.QueryRow(ctx, ` SELECT id::text FROM users WHERE external_id = $1 `, externalUserID).Scan(&userID) if err != nil { if err == pgx.ErrNoRows { return "", fmt.Errorf("user not found") } return "", err } return userID, nil } func nullStr(s string) *string { if s == "" { return nil } return &s }