Files
pikasTech-HWLAB/internal/userbilling/service.go
T
2026-06-14 01:14:07 +08:00

1557 lines
54 KiB
Go

package userbilling
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"embed"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"math"
"net/http"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
"time"
_ "github.com/jackc/pgx/v4/stdlib"
)
const serviceID = "hwlab-user-billing"
//go:embed migrations/*.sql
var migrationFS embed.FS
type Config struct {
Addr string
DatabaseURL string
RedisURL string
InitialCredits int64
CreditPerThousandTok int64
SessionTTL time.Duration
ReservationTTL time.Duration
RegistrationEnabled bool
InternalToken string
RequireInternalToken bool
MigrateOnStart bool
StateAuthority string
RuntimeNamespace string
ExternalDatabaseLabel string
}
type Server struct {
config Config
db *sql.DB
mux *http.ServeMux
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
Role string `json:"role"`
EmailVerified bool `json:"emailVerified"`
CreatedAt time.Time `json:"createdAt"`
}
type Principal struct {
UserID string `json:"userId"`
Email string `json:"email"`
Username string `json:"username"`
Role string `json:"role"`
Scopes []string `json:"scopes"`
AuthType string `json:"authType"`
KeyID string `json:"keyId,omitempty"`
}
type serviceUsageSummary struct {
ServiceID string `json:"serviceId"`
Credits int64 `json:"credits"`
Quantity int64 `json:"quantity"`
RecordCount int64 `json:"recordCount"`
LastUsedAt time.Time `json:"lastUsedAt"`
}
type ledgerSummaryRow struct {
ID string `json:"id"`
Kind string `json:"kind"`
Reason string `json:"reason"`
DeltaCredits int64 `json:"deltaCredits"`
BalanceAfter int64 `json:"balanceAfter"`
CreatedAt time.Time `json:"createdAt"`
}
type reservationSummaryRow struct {
ReservationID string `json:"reservationId"`
ServiceID string `json:"serviceId"`
EstimatedCredits int64 `json:"estimatedCredits"`
EstimatedTokens int64 `json:"estimatedTokens"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
type adminBillingUserSummary struct {
User User `json:"user"`
PlanID string `json:"planId"`
Credits creditSummary `json:"credits"`
Usage userUsageAggregate `json:"usage"`
APIKeys apiKeyAggregate `json:"apiKeys"`
Reservations reservationAdminSummary `json:"reservations"`
}
type creditSummary struct {
Balance int64 `json:"balance"`
Reserved int64 `json:"reserved"`
Available int64 `json:"available"`
}
type userUsageAggregate struct {
TotalCredits int64 `json:"totalCredits"`
TotalQuantity int64 `json:"totalQuantity"`
RecordCount int64 `json:"recordCount"`
LastUsedAt *time.Time `json:"lastUsedAt"`
}
type apiKeyAggregate struct {
ActiveCount int64 `json:"activeCount"`
TotalCount int64 `json:"totalCount"`
LastUsedAt *time.Time `json:"lastUsedAt"`
}
type reservationAdminSummary struct {
ActiveCount int64 `json:"activeCount"`
Active []reservationSummaryRow `json:"active"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func Run(parent context.Context) error {
cfg := LoadConfig(os.Environ())
server, err := NewServer(parent, cfg)
if err != nil {
return err
}
defer server.Close()
ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
defer stop()
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: server,
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Printf("%s listening on %s", serviceID, cfg.Addr)
errCh <- httpServer.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
return nil
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
func LoadConfig(env []string) Config {
values := map[string]string{}
for _, item := range env {
name, value, ok := strings.Cut(item, "=")
if ok {
values[name] = value
}
}
port := first(values["HWLAB_USER_BILLING_PORT"], values["PORT"], "6670")
cfg := Config{
Addr: first(values["HWLAB_USER_BILLING_ADDR"], ":"+port),
DatabaseURL: first(values["HWLAB_USER_BILLING_DB_URL"], values["DATABASE_URL"]),
RedisURL: values["HWLAB_USER_BILLING_REDIS_URL"],
InitialCredits: parseInt64(values["HWLAB_USER_BILLING_INITIAL_CREDITS"], 0),
CreditPerThousandTok: parseInt64(values["HWLAB_USER_BILLING_CREDIT_PER_1K_TOKENS"], 1),
SessionTTL: parseDurationHours(values["HWLAB_USER_BILLING_SESSION_TTL_HOURS"], 720),
ReservationTTL: parseDurationMinutes(values["HWLAB_USER_BILLING_RESERVATION_TTL_MINUTES"], 30),
RegistrationEnabled: values["HWLAB_USER_BILLING_REGISTRATION_ENABLED"] != "0",
InternalToken: values["HWLAB_USER_BILLING_INTERNAL_TOKEN"],
RequireInternalToken: values["HWLAB_USER_BILLING_REQUIRE_INTERNAL_TOKEN"] == "1",
MigrateOnStart: values["HWLAB_USER_BILLING_MIGRATE_ON_START"] != "0",
StateAuthority: first(values["HWLAB_USER_BILLING_STATE_AUTHORITY"], "pk01-postgres"),
RuntimeNamespace: first(values["POD_NAMESPACE"], values["HWLAB_NAMESPACE"], "hwlab-v03"),
ExternalDatabaseLabel: first(values["HWLAB_USER_BILLING_EXTERNAL_DB_LABEL"], "pk01-external-postgres"),
}
return cfg
}
func NewServer(ctx context.Context, cfg Config) (*Server, error) {
server := &Server{config: cfg, mux: http.NewServeMux()}
if cfg.DatabaseURL != "" {
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
server.db = db
if cfg.MigrateOnStart {
migrateCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if err := server.migrate(migrateCtx); err != nil {
return nil, fmt.Errorf("migrate user billing schema: %w", err)
}
}
}
server.routes()
return server, nil
}
func (s *Server) Close() {
if s.db != nil {
_ = s.db.Close()
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
func (s *Server) routes() {
s.route(http.MethodGet, "/health/live", s.handleLive)
s.route(http.MethodGet, "/health/ready", s.handleReady)
s.route(http.MethodPost, "/v1/auth/register", s.handleRegister)
s.route(http.MethodPost, "/v1/auth/login", s.handleLogin)
s.route(http.MethodPost, "/v1/auth/logout", s.handleLogout)
s.route(http.MethodPost, "/v1/auth/refresh", s.handleRefresh)
s.route(http.MethodGet, "/v1/me", s.handleMe)
s.route(http.MethodGet, "/v1/billing/summary", s.handleBillingSummary)
s.route(http.MethodPost, "/v1/api-keys", s.handleCreateAPIKey)
s.route(http.MethodPost, "/internal/auth/introspect", s.handleIntrospect)
s.route(http.MethodPost, "/internal/billing/preflight", s.handleBillingPreflight)
s.route(http.MethodPost, "/internal/billing/record", s.handleBillingRecord)
s.route(http.MethodGet, "/internal/admin/billing/summary", s.handleAdminBillingSummary)
s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust)
s.route(http.MethodPost, "/internal/admin/users/status", s.handleAdminUserStatus)
}
func (s *Server) route(method, path string, handler http.HandlerFunc) {
s.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
if r.Method != method {
w.Header().Set("Allow", method)
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
handler(w, r)
})
}
func (s *Server) migrate(ctx context.Context) error {
if s.db == nil {
return nil
}
files, err := fs.Glob(migrationFS, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(files)
for _, name := range files {
body, err := migrationFS.ReadFile(name)
if err != nil {
return err
}
for _, statement := range splitSQLStatements(string(body)) {
if _, err := s.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
}
}
return nil
}
func splitSQLStatements(text string) []string {
parts := strings.Split(text, ";\n")
statements := make([]string, 0, len(parts))
for _, part := range parts {
stmt := strings.TrimSpace(part)
if stmt == "" {
continue
}
if !strings.HasSuffix(stmt, ";") {
stmt += ";"
}
statements = append(statements, stmt)
}
return statements
}
func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"serviceId": serviceID,
"status": "ok",
"stateless": true,
"stateAuthority": s.config.StateAuthority,
"database": map[string]any{"configured": s.db != nil, "authority": s.config.ExternalDatabaseLabel},
"redis": map[string]any{"configured": s.config.RedisURL != "", "role": "cache-only"},
"namespace": s.config.RuntimeNamespace,
"registrationEnabled": s.config.RegistrationEnabled,
"internalTokenRequired": s.config.RequireInternalToken,
})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
if s.db == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"serviceId": serviceID, "status": "degraded", "reason": "database-url-not-configured"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
s.logDatabaseError("ready_ping", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"serviceId": serviceID, "status": "degraded", "reason": "database-ping-failed", "database": map[string]any{"configured": true, "authority": s.config.ExternalDatabaseLabel, "errorClass": classifyDatabaseError(err)}})
return
}
if err := s.readyWriteProbe(ctx); err != nil {
s.logDatabaseError("ready_write_probe", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"serviceId": serviceID, "status": "degraded", "reason": "database-transaction-probe-failed", "database": map[string]any{"configured": true, "authority": s.config.ExternalDatabaseLabel, "errorClass": classifyDatabaseError(err)}})
return
}
writeJSON(w, http.StatusOK, map[string]any{"serviceId": serviceID, "status": "ready", "database": map[string]any{"configured": true, "authority": s.config.ExternalDatabaseLabel, "transactionProbe": "write-rollback-ok"}})
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if !s.registrationAvailable(w) {
return
}
var req struct {
Email string `json:"email"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &req) {
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
username := normalizeUsername(req.Username)
if email == "" || !strings.Contains(email, "@") || username == "" || len(req.Password) < 10 {
writeAPIError(w, http.StatusBadRequest, "invalid_registration", "email, username and a password with at least 10 characters are required")
return
}
passwordHash, err := hashPassword(req.Password)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "password_hash_failed", "could not hash password")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
s.logDatabaseError("register_begin_tx", err)
writeAPIError(w, http.StatusInternalServerError, "transaction_failed", "could not start transaction")
return
}
defer tx.Rollback()
user := User{ID: newID("usr"), Email: email, Username: username, DisplayName: strings.TrimSpace(req.DisplayName), Status: "active", Role: "user"}
err = tx.QueryRowContext(ctx, `INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role) VALUES ($1, $2, $3, $4, $5, 'active', 'user') RETURNING created_at`, user.ID, user.Email, user.Username, user.DisplayName, passwordHash).Scan(&user.CreatedAt)
if err != nil {
if isDuplicate(err) {
writeAPIError(w, http.StatusConflict, "user_exists", "email or username already exists")
return
}
writeAPIError(w, http.StatusInternalServerError, "user_create_failed", "could not create user")
return
}
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_accounts (user_id, balance_credits, reserved_credits, plan_id) VALUES ($1, $2, 0, 'default')`, user.ID, s.config.InitialCredits)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "credit_account_failed", "could not create credit account")
return
}
if s.config.InitialCredits > 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_after, kind, reason, metadata) VALUES ($1, $2, $3, $3, 'grant', 'registration_initial_credit', '{}'::jsonb)`, newID("led"), user.ID, s.config.InitialCredits)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "credit_ledger_failed", "could not write initial credit ledger")
return
}
}
if err := tx.Commit(); err != nil {
writeAPIError(w, http.StatusInternalServerError, "transaction_commit_failed", "could not commit registration")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"user": user, "initialCredits": s.config.InitialCredits})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if !s.databaseAvailable(w) {
return
}
var req struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &req) {
return
}
login := strings.ToLower(strings.TrimSpace(first(req.Email, req.Username)))
if login == "" || req.Password == "" {
writeAPIError(w, http.StatusBadRequest, "invalid_login", "email or username and password are required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var user User
var passwordHash string
err := s.db.QueryRowContext(ctx, `SELECT id, email, username, display_name, password_hash, status, role, email_verified, created_at FROM hwlab_users WHERE lower(email) = $1 OR lower(username) = $1`, login).Scan(&user.ID, &user.Email, &user.Username, &user.DisplayName, &passwordHash, &user.Status, &user.Role, &user.EmailVerified, &user.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
writeAPIError(w, http.StatusUnauthorized, "invalid_credentials", "invalid email, username or password")
return
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "login_failed", "could not load user")
return
}
if !verifyPassword(passwordHash, req.Password) {
writeAPIError(w, http.StatusUnauthorized, "invalid_credentials", "invalid email, username or password")
return
}
if user.Status != "active" {
writeAPIError(w, http.StatusForbidden, "user_disabled", "user is not active")
return
}
sessionToken := "hws_" + randomToken(32)
expiresAt := time.Now().UTC().Add(s.config.SessionTTL)
_, err = s.db.ExecContext(ctx, `INSERT INTO hwlab_user_sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)`, newID("ses"), user.ID, tokenHash(sessionToken), expiresAt)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "session_create_failed", "could not create session")
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": sessionToken, "tokenType": "Bearer", "expiresAt": expiresAt, "user": user})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if !s.databaseAvailable(w) {
return
}
token := bearerToken(r)
if token == "" {
writeAPIError(w, http.StatusUnauthorized, "missing_bearer", "authorization bearer token is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
_, _ = s.db.ExecContext(ctx, `UPDATE hwlab_user_sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, tokenHash(token))
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
principal, ok := s.requirePrincipal(w, r)
if !ok {
return
}
if principal.AuthType != "session" {
writeAPIError(w, http.StatusForbidden, "session_required", "only session tokens can be refreshed")
return
}
expiresAt := time.Now().UTC().Add(s.config.SessionTTL)
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
_, err := s.db.ExecContext(ctx, `UPDATE hwlab_user_sessions SET expires_at = $1 WHERE token_hash = $2 AND revoked_at IS NULL`, expiresAt, tokenHash(bearerToken(r)))
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "session_refresh_failed", "could not refresh session")
return
}
writeJSON(w, http.StatusOK, map[string]any{"expiresAt": expiresAt})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
principal, ok := s.requirePrincipal(w, r)
if !ok {
return
}
balance, reserved, _ := s.accountBalance(r.Context(), principal.UserID)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal, "credits": map[string]any{"balance": balance, "reserved": reserved, "available": balance - reserved}})
}
func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) {
principal, ok := s.requirePrincipal(w, r)
if !ok {
return
}
limit := queryLimit(r, "limit", 20, 100)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
balance, reserved, err := s.accountBalance(ctx, principal.UserID)
if err != nil {
s.logDatabaseError("billing_summary_account", err)
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load credit account")
return
}
totalCredits, totalQuantity, recordCount, err := s.usageTotals(ctx, principal.UserID)
if err != nil {
s.logDatabaseError("billing_summary_usage_totals", err)
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load usage totals")
return
}
byService, err := s.usageByService(ctx, principal.UserID, 20)
if err != nil {
s.logDatabaseError("billing_summary_usage_by_service", err)
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load service usage")
return
}
ledgerRows, err := s.ledgerRows(ctx, principal.UserID, limit)
if err != nil {
s.logDatabaseError("billing_summary_ledger", err)
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load credit ledger")
return
}
reservations, err := s.activeReservations(ctx, principal.UserID, 20)
if err != nil {
s.logDatabaseError("billing_summary_reservations", err)
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load active reservations")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"contractVersion": "user-billing-summary-v1",
"serviceId": serviceID,
"status": "ok",
"principal": principal,
"credits": map[string]any{
"balance": balance,
"reserved": reserved,
"available": balance - reserved,
},
"usage": map[string]any{
"totalCredits": totalCredits,
"totalQuantity": totalQuantity,
"recordCount": recordCount,
"byService": byService,
},
"ledger": map[string]any{
"count": len(ledgerRows),
"limit": limit,
"rows": ledgerRows,
},
"reservations": map[string]any{
"activeCount": len(reservations),
"active": reservations,
},
"state": map[string]any{
"stateless": true,
"stateAuthority": s.config.StateAuthority,
"database": map[string]any{"authority": s.config.ExternalDatabaseLabel},
"redis": map[string]any{"role": "cache-only"},
},
"valuesRedacted": true,
})
}
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
principal, ok := s.requirePrincipal(w, r)
if !ok {
return
}
var req struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
if !decodeJSON(w, r, &req) {
return
}
name := strings.TrimSpace(first(req.Name, "default"))
scopes := req.Scopes
if len(scopes) == 0 {
scopes = []string{"api"}
}
scopesJSON, _ := json.Marshal(scopes)
key := "hwl_" + randomToken(32)
keyID := newID("key")
prefix := key[:min(len(key), 16)]
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err := s.db.ExecContext(ctx, `INSERT INTO hwlab_api_keys (id, user_id, name, key_prefix, key_hash, scopes_json) VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, keyID, principal.UserID, name, prefix, tokenHash(key), string(scopesJSON))
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "api_key_create_failed", "could not create API key")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": keyID, "key": key, "prefix": prefix, "scopes": scopes})
}
func (s *Server) handleIntrospect(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
var req struct {
Token string `json:"token"`
APIKey string `json:"apiKey"`
}
if !decodeJSON(w, r, &req) {
return
}
token := first(req.Token, req.APIKey)
principal, err := s.principalFromToken(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"active": false})
return
}
writeJSON(w, http.StatusOK, map[string]any{"active": true, "principal": principal})
}
func (s *Server) handleBillingPreflight(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
var req struct {
Token string `json:"token"`
APIKey string `json:"apiKey"`
UserID string `json:"userId"`
ServiceID string `json:"serviceId"`
EstimatedCredits int64 `json:"estimatedCredits"`
EstimatedTokens int64 `json:"estimatedTokens"`
IdempotencyKey string `json:"idempotencyKey"`
Metadata map[string]any `json:"metadata"`
}
if !decodeJSON(w, r, &req) {
return
}
principal, err := s.principalForBilling(r.Context(), r, req.Token, req.APIKey, req.UserID)
if err != nil {
writeAPIError(w, http.StatusUnauthorized, "invalid_subject", "billing subject is not active")
return
}
service := strings.TrimSpace(req.ServiceID)
if service == "" {
writeAPIError(w, http.StatusBadRequest, "missing_service", "serviceId is required")
return
}
estimated := s.estimateCredits(req.EstimatedCredits, req.EstimatedTokens)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
reservation, err := s.reserveCredits(ctx, principal.UserID, service, estimated, req.EstimatedTokens, strings.TrimSpace(req.IdempotencyKey), req.Metadata)
if err != nil {
if errors.Is(err, errInsufficientCredits) {
writeJSON(w, http.StatusPaymentRequired, map[string]any{"allowed": false, "reason": "insufficient_credits"})
return
}
writeAPIError(w, http.StatusInternalServerError, "billing_preflight_failed", "could not reserve credits")
return
}
writeJSON(w, http.StatusOK, reservation)
}
func (s *Server) handleBillingRecord(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
var req struct {
ReservationID string `json:"reservationId"`
Token string `json:"token"`
APIKey string `json:"apiKey"`
UserID string `json:"userId"`
ServiceID string `json:"serviceId"`
UsedCredits int64 `json:"usedCredits"`
UsedTokens int64 `json:"usedTokens"`
IdempotencyKey string `json:"idempotencyKey"`
Metadata map[string]any `json:"metadata"`
}
if !decodeJSON(w, r, &req) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
result, err := s.recordUsage(ctx, r, req.ReservationID, req.Token, req.APIKey, req.UserID, req.ServiceID, req.UsedCredits, req.UsedTokens, req.IdempotencyKey, req.Metadata)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "billing_record_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminCreditAdjust(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
var req struct {
UserID string `json:"userId"`
DeltaCredits int64 `json:"deltaCredits"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotencyKey"`
Metadata map[string]any `json:"metadata"`
}
if !decodeJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.UserID) == "" || req.DeltaCredits == 0 {
writeAPIError(w, http.StatusBadRequest, "invalid_adjustment", "userId and non-zero deltaCredits are required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
balance, err := s.adjustCredits(ctx, req.UserID, req.DeltaCredits, "admin_adjust", req.Reason, req.IdempotencyKey, req.Metadata)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "credit_adjust_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "balance": balance})
}
func (s *Server) handleAdminUserStatus(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
var req struct {
UserID string `json:"userId"`
Status string `json:"status"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Status != "active" && req.Status != "disabled" && req.Status != "pending" {
writeAPIError(w, http.StatusBadRequest, "invalid_status", "status must be active, disabled or pending")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
res, err := s.db.ExecContext(ctx, `UPDATE hwlab_users SET status = $2, updated_at = now() WHERE id = $1`, req.UserID, req.Status)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "status_update_failed", "could not update user status")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "status": req.Status})
}
func (s *Server) handleAdminBillingSummary(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
limit := queryLimit(r, "limit", 50, 100)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
users, err := s.adminBillingUsers(ctx, limit)
if err != nil {
s.logDatabaseError("admin_billing_summary_users", err)
writeAPIError(w, http.StatusInternalServerError, "admin_billing_summary_failed", "could not load admin billing summary")
return
}
totals, err := s.adminBillingTotals(ctx)
if err != nil {
s.logDatabaseError("admin_billing_summary_totals", err)
writeAPIError(w, http.StatusInternalServerError, "admin_billing_summary_failed", "could not load admin billing totals")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"contractVersion": "user-billing-admin-summary-v1",
"serviceId": serviceID,
"status": "ok",
"users": users,
"count": len(users),
"limit": limit,
"totals": totals,
"state": map[string]any{
"stateless": true,
"stateAuthority": s.config.StateAuthority,
"database": map[string]any{"authority": s.config.ExternalDatabaseLabel},
"redis": map[string]any{"role": "cache-only"},
},
"valuesRedacted": true,
})
}
var errInsufficientCredits = errors.New("insufficient credits")
func (s *Server) reserveCredits(ctx context.Context, userID, service string, credits, tokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return nil, err
}
defer tx.Rollback()
if idempotencyKey != "" {
var existingID, status string
var estimated int64
err := tx.QueryRowContext(ctx, `SELECT id, status, estimated_credits FROM hwlab_billing_reservations WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID, &status, &estimated)
if err == nil {
return map[string]any{"allowed": status == "reserved", "reservationId": existingID, "estimatedCredits": estimated, "status": status, "idempotent": true}, tx.Commit()
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
}
var balance, reserved int64
if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &reserved); err != nil {
return nil, err
}
available := balance - reserved
if available < credits {
return nil, errInsufficientCredits
}
reservationID := newID("res")
metadataJSON := jsonObject(metadata)
expiresAt := time.Now().UTC().Add(s.config.ReservationTTL)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_billing_reservations (id, user_id, service_id, estimated_credits, estimated_tokens, idempotency_key, metadata, expires_at) VALUES ($1, $2, $3, $4, $5, nullif($6, ''), $7::jsonb, $8)`, reservationID, userID, service, credits, nonNegative(tokens), idempotencyKey, metadataJSON, expiresAt)
if err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET reserved_credits = reserved_credits + $2, updated_at = now() WHERE user_id = $1`, userID, credits)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return map[string]any{"allowed": true, "reservationId": reservationID, "estimatedCredits": credits, "estimatedTokens": tokens, "availableBefore": available, "expiresAt": expiresAt}, nil
}
func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID, token, apiKey, userID, serviceID string, usedCredits, usedTokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return nil, err
}
defer tx.Rollback()
if idempotencyKey != "" {
var existingID string
err := tx.QueryRowContext(ctx, `SELECT id FROM hwlab_usage_records WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
if err == nil {
return map[string]any{"recordId": existingID, "idempotent": true}, tx.Commit()
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
}
var reserved int64
if reservationID != "" {
var status string
err := tx.QueryRowContext(ctx, `SELECT user_id, service_id, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &serviceID, &reserved, &status)
if err != nil {
return nil, err
}
if status != "reserved" {
return nil, fmt.Errorf("reservation status is %s", status)
}
} else {
principal, err := s.principalForBilling(ctx, r, token, apiKey, userID)
if err != nil {
return nil, err
}
userID = principal.UserID
serviceID = strings.TrimSpace(serviceID)
}
if serviceID == "" {
return nil, errors.New("serviceId is required")
}
credits := s.estimateCredits(usedCredits, usedTokens)
var balance, accountReserved int64
if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &accountReserved); err != nil {
return nil, err
}
if reservationID == "" && balance-accountReserved < credits {
return nil, errInsufficientCredits
}
newReserved := accountReserved - reserved
if newReserved < 0 {
newReserved = 0
}
newBalance := balance - credits
if newBalance < 0 {
return nil, errInsufficientCredits
}
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET balance_credits = $2, reserved_credits = $3, updated_at = now() WHERE user_id = $1`, userID, newBalance, newReserved)
if err != nil {
return nil, err
}
recordID := newID("use")
metadataJSON := jsonObject(metadata)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_usage_records (id, reservation_id, user_id, service_id, quantity, credits, idempotency_key, metadata) VALUES ($1, nullif($2, ''), $3, $4, $5, $6, nullif($7, ''), $8::jsonb)`, recordID, reservationID, userID, serviceID, nonNegative(usedTokens), credits, idempotencyKey, metadataJSON)
if err != nil {
return nil, err
}
usageLedgerKey := ""
if idempotencyKey != "" {
usageLedgerKey = "usage:" + idempotencyKey
}
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_after, kind, reason, idempotency_key, metadata) VALUES ($1, $2, $3, $4, 'usage', $5, nullif($6, ''), $7::jsonb)`, newID("led"), userID, -credits, newBalance, serviceID, usageLedgerKey, metadataJSON)
if err != nil {
return nil, err
}
if reservationID != "" {
_, err = tx.ExecContext(ctx, `UPDATE hwlab_billing_reservations SET status = 'completed', updated_at = now() WHERE id = $1`, reservationID)
if err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return map[string]any{"recordId": recordID, "userId": userID, "serviceId": serviceID, "credits": credits, "balance": newBalance}, nil
}
func (s *Server) adjustCredits(ctx context.Context, userID string, delta int64, kind, reason, idempotencyKey string, metadata map[string]any) (int64, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return 0, err
}
defer tx.Rollback()
if idempotencyKey != "" {
var balance int64
err := tx.QueryRowContext(ctx, `SELECT balance_after FROM hwlab_credit_ledger WHERE idempotency_key = $1`, idempotencyKey).Scan(&balance)
if err == nil {
return balance, tx.Commit()
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
}
var balance int64
if err := tx.QueryRowContext(ctx, `SELECT balance_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance); err != nil {
return 0, err
}
newBalance := balance + delta
if newBalance < 0 {
return 0, errInsufficientCredits
}
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET balance_credits = $2, updated_at = now() WHERE user_id = $1`, userID, newBalance)
if err != nil {
return 0, err
}
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_after, kind, reason, idempotency_key, metadata) VALUES ($1, $2, $3, $4, $5, $6, nullif($7, ''), $8::jsonb)`, newID("led"), userID, delta, newBalance, kind, reason, idempotencyKey, jsonObject(metadata))
if err != nil {
return 0, err
}
return newBalance, tx.Commit()
}
func (s *Server) principalForBilling(ctx context.Context, r *http.Request, token, apiKey, userID string) (*Principal, error) {
if token == "" && apiKey == "" {
token = bearerToken(r)
}
if token != "" || apiKey != "" {
return s.principalFromToken(ctx, first(token, apiKey))
}
userID = strings.TrimSpace(userID)
if userID == "" {
return nil, errors.New("billing subject is required")
}
var principal Principal
err := s.db.QueryRowContext(ctx, `SELECT id, email, username, role FROM hwlab_users WHERE id = $1 AND status = 'active'`, userID).Scan(&principal.UserID, &principal.Email, &principal.Username, &principal.Role)
if err != nil {
return nil, err
}
principal.AuthType = "internal-user-id"
principal.Scopes = []string{"api"}
return &principal, nil
}
func (s *Server) principalFromToken(ctx context.Context, token string) (*Principal, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, errors.New("empty token")
}
hash := tokenHash(token)
var principal Principal
if strings.HasPrefix(token, "hwl_") {
var scopesRaw string
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.email, u.username, u.role, k.id, k.scopes_json::text FROM hwlab_api_keys k JOIN hwlab_users u ON u.id = k.user_id WHERE k.key_hash = $1 AND k.status = 'active' AND k.revoked_at IS NULL AND u.status = 'active'`, hash).Scan(&principal.UserID, &principal.Email, &principal.Username, &principal.Role, &principal.KeyID, &scopesRaw)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(scopesRaw), &principal.Scopes)
if len(principal.Scopes) == 0 {
principal.Scopes = []string{"api"}
}
principal.AuthType = "api-key"
_, _ = s.db.ExecContext(ctx, `UPDATE hwlab_api_keys SET last_used_at = now() WHERE id = $1`, principal.KeyID)
return &principal, nil
}
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.email, u.username, u.role FROM hwlab_user_sessions s JOIN hwlab_users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() AND u.status = 'active'`, hash).Scan(&principal.UserID, &principal.Email, &principal.Username, &principal.Role)
if err != nil {
return nil, err
}
principal.AuthType = "session"
principal.Scopes = []string{"session"}
return &principal, nil
}
func (s *Server) requirePrincipal(w http.ResponseWriter, r *http.Request) (*Principal, bool) {
if !s.databaseAvailable(w) {
return nil, false
}
principal, err := s.principalFromToken(r.Context(), bearerToken(r))
if err != nil {
writeAPIError(w, http.StatusUnauthorized, "unauthorized", "valid bearer session or API key is required")
return nil, false
}
return principal, true
}
func (s *Server) internalAllowed(w http.ResponseWriter, r *http.Request) bool {
if s.config.InternalToken == "" {
if s.config.RequireInternalToken {
writeAPIError(w, http.StatusServiceUnavailable, "internal_token_missing", "internal token is required but not configured")
return false
}
return true
}
candidate := strings.TrimSpace(r.Header.Get("X-HWLAB-Internal-Token"))
if candidate == "" {
candidate = bearerToken(r)
}
if subtle.ConstantTimeCompare([]byte(candidate), []byte(s.config.InternalToken)) != 1 {
writeAPIError(w, http.StatusUnauthorized, "internal_auth_failed", "valid internal token is required")
return false
}
return true
}
func (s *Server) databaseAvailable(w http.ResponseWriter) bool {
if s.db != nil {
return true
}
writeAPIError(w, http.StatusServiceUnavailable, "state_backend_not_configured", "HWLAB_USER_BILLING_DB_URL must point to the PK01 external PostgreSQL authority")
return false
}
func (s *Server) registrationAvailable(w http.ResponseWriter) bool {
if !s.RegistrationEnabled() {
writeAPIError(w, http.StatusForbidden, "registration_disabled", "registration is disabled")
return false
}
return s.databaseAvailable(w)
}
func (s *Server) RegistrationEnabled() bool {
return s.config.RegistrationEnabled
}
func (s *Server) readyWriteProbe(ctx context.Context) error {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return err
}
probeID := newID("usr_probe")
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role) VALUES ($1, $2, $3, 'readiness probe', 'readiness-probe', 'pending', 'user')`, probeID, probeID+"@readiness.invalid", probeID)
if err != nil {
_ = tx.Rollback()
return err
}
return tx.Rollback()
}
func (s *Server) logDatabaseError(operation string, err error) {
log.Printf("%s database operation=%s errorClass=%s errorCode=%s valuesRedacted=true", serviceID, operation, classifyDatabaseError(err), databaseErrorCode(err))
}
func classifyDatabaseError(err error) string {
if err == nil {
return "none"
}
text := strings.ToLower(err.Error())
code := strings.ToUpper(databaseErrorCode(err))
if code == "57014" || errors.Is(err, context.DeadlineExceeded) || strings.Contains(text, "deadline exceeded") || strings.Contains(text, "timeout") {
return "timeout"
}
if strings.Contains(text, "certificate") || strings.Contains(text, "ssl") || strings.Contains(text, "tls") {
return "ssl"
}
if strings.Contains(text, "password") || strings.Contains(text, "authentication") || strings.Contains(text, "pg_hba") || code == "28P01" || strings.HasPrefix(code, "28") {
return "auth"
}
if strings.Contains(text, "too many connections") || strings.Contains(text, "connection refused") || strings.Contains(text, "connection reset") || strings.Contains(text, "broken pipe") {
return "connection"
}
return "database"
}
func databaseErrorCode(err error) string {
if err == nil {
return ""
}
type sqlState interface{ SQLState() string }
var state sqlState
if errors.As(err, &state) {
return state.SQLState()
}
type coder interface{ Code() string }
var coded coder
if errors.As(err, &coded) {
return coded.Code()
}
return ""
}
func (s *Server) accountBalance(ctx context.Context, userID string) (int64, int64, error) {
var balance, reserved int64
err := s.db.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits FROM hwlab_credit_accounts WHERE user_id = $1`, userID).Scan(&balance, &reserved)
return balance, reserved, err
}
func (s *Server) usageTotals(ctx context.Context, userID string) (int64, int64, int64, error) {
var credits, quantity, count int64
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*) FROM hwlab_usage_records WHERE user_id = $1`, userID).Scan(&credits, &quantity, &count)
return credits, quantity, count, err
}
func (s *Server) usageByService(ctx context.Context, userID string, limit int) ([]serviceUsageSummary, error) {
rows, err := s.db.QueryContext(ctx, `SELECT service_id, COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*), MAX(created_at) FROM hwlab_usage_records WHERE user_id = $1 GROUP BY service_id ORDER BY MAX(created_at) DESC NULLS LAST, service_id ASC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []serviceUsageSummary{}
for rows.Next() {
var item serviceUsageSummary
if err := rows.Scan(&item.ServiceID, &item.Credits, &item.Quantity, &item.RecordCount, &item.LastUsedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Server) ledgerRows(ctx context.Context, userID string, limit int) ([]ledgerSummaryRow, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, kind, reason, delta_credits, balance_after, created_at FROM hwlab_credit_ledger WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ledgerSummaryRow{}
for rows.Next() {
var item ledgerSummaryRow
if err := rows.Scan(&item.ID, &item.Kind, &item.Reason, &item.DeltaCredits, &item.BalanceAfter, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Server) activeReservations(ctx context.Context, userID string, limit int) ([]reservationSummaryRow, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, service_id, estimated_credits, estimated_tokens, status, expires_at, created_at FROM hwlab_billing_reservations WHERE user_id = $1 AND status = 'reserved' AND expires_at > now() ORDER BY expires_at ASC, created_at DESC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []reservationSummaryRow{}
for rows.Next() {
var item reservationSummaryRow
if err := rows.Scan(&item.ReservationID, &item.ServiceID, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Server) adminBillingUsers(ctx context.Context, limit int) ([]adminBillingUserSummary, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT
u.id,
u.email,
u.username,
u.display_name,
u.status,
u.role,
u.email_verified,
u.created_at,
COALESCE(a.plan_id, 'default'),
COALESCE(a.balance_credits, 0),
COALESCE(a.reserved_credits, 0),
COALESCE(usage.total_credits, 0),
COALESCE(usage.total_quantity, 0),
COALESCE(usage.record_count, 0),
usage.last_used_at,
COALESCE(keys.active_count, 0),
COALESCE(keys.total_count, 0),
keys.last_key_used_at,
COALESCE(res.active_count, 0)
FROM hwlab_users u
LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id
LEFT JOIN (
SELECT user_id, SUM(credits) AS total_credits, SUM(quantity) AS total_quantity, COUNT(*) AS record_count, MAX(created_at) AS last_used_at
FROM hwlab_usage_records
GROUP BY user_id
) usage ON usage.user_id = u.id
LEFT JOIN (
SELECT user_id, COUNT(*) FILTER (WHERE status = 'active' AND revoked_at IS NULL) AS active_count, COUNT(*) AS total_count, MAX(last_used_at) AS last_key_used_at
FROM hwlab_api_keys
GROUP BY user_id
) keys ON keys.user_id = u.id
LEFT JOIN (
SELECT user_id, COUNT(*) AS active_count
FROM hwlab_billing_reservations
WHERE status = 'reserved' AND expires_at > now()
GROUP BY user_id
) res ON res.user_id = u.id
ORDER BY usage.last_used_at DESC NULLS LAST, u.created_at DESC, u.username ASC
LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
users := []adminBillingUserSummary{}
userIDs := []string{}
for rows.Next() {
var item adminBillingUserSummary
var lastUsed sql.NullTime
var lastKeyUsed sql.NullTime
if err := rows.Scan(&item.User.ID, &item.User.Email, &item.User.Username, &item.User.DisplayName, &item.User.Status, &item.User.Role, &item.User.EmailVerified, &item.User.CreatedAt, &item.PlanID, &item.Credits.Balance, &item.Credits.Reserved, &item.Usage.TotalCredits, &item.Usage.TotalQuantity, &item.Usage.RecordCount, &lastUsed, &item.APIKeys.ActiveCount, &item.APIKeys.TotalCount, &lastKeyUsed, &item.Reservations.ActiveCount); err != nil {
return nil, err
}
if lastUsed.Valid {
value := lastUsed.Time
item.Usage.LastUsedAt = &value
}
if lastKeyUsed.Valid {
value := lastKeyUsed.Time
item.APIKeys.LastUsedAt = &value
}
item.Credits.Available = item.Credits.Balance - item.Credits.Reserved
userIDs = append(userIDs, item.User.ID)
users = append(users, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
reservations, err := s.activeReservationsForUsers(ctx, userIDs, 5)
if err != nil {
return nil, err
}
for i := range users {
users[i].Reservations.Active = reservations[users[i].User.ID]
}
return users, nil
}
func (s *Server) activeReservationsForUsers(ctx context.Context, userIDs []string, perUserLimit int) (map[string][]reservationSummaryRow, error) {
items := map[string][]reservationSummaryRow{}
if len(userIDs) == 0 {
return items, nil
}
placeholders := make([]string, len(userIDs))
args := make([]any, 0, len(userIDs)+1)
for i, userID := range userIDs {
placeholders[i] = "$" + strconv.Itoa(i+1)
args = append(args, userID)
}
limitParam := "$" + strconv.Itoa(len(args)+1)
args = append(args, perUserLimit)
query := `
SELECT user_id, id, service_id, estimated_credits, estimated_tokens, status, expires_at, created_at
FROM (
SELECT user_id, id, service_id, estimated_credits, estimated_tokens, status, expires_at, created_at,
row_number() OVER (PARTITION BY user_id ORDER BY expires_at ASC, created_at DESC) AS rn
FROM hwlab_billing_reservations
WHERE user_id IN (` + strings.Join(placeholders, ", ") + `) AND status = 'reserved' AND expires_at > now()
) ranked
WHERE rn <= ` + limitParam + `
ORDER BY expires_at ASC, created_at DESC`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var userID string
var item reservationSummaryRow
if err := rows.Scan(&userID, &item.ReservationID, &item.ServiceID, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil {
return nil, err
}
items[userID] = append(items[userID], item)
}
return items, rows.Err()
}
func (s *Server) adminBillingTotals(ctx context.Context) (map[string]any, error) {
var totals struct {
Users int64
ActiveUsers int64
DisabledUsers int64
PendingUsers int64
BalanceCredits int64
ReservedCredits int64
AvailableCredits int64
UsageCredits int64
UsageQuantity int64
UsageRecords int64
ActiveReservations int64
}
err := s.db.QueryRowContext(ctx, `
SELECT
(SELECT COUNT(*) FROM hwlab_users),
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'active'),
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'disabled'),
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'pending'),
COALESCE((SELECT SUM(balance_credits) FROM hwlab_credit_accounts), 0),
COALESCE((SELECT SUM(reserved_credits) FROM hwlab_credit_accounts), 0),
COALESCE((SELECT SUM(balance_credits - reserved_credits) FROM hwlab_credit_accounts), 0),
COALESCE((SELECT SUM(credits) FROM hwlab_usage_records), 0),
COALESCE((SELECT SUM(quantity) FROM hwlab_usage_records), 0),
(SELECT COUNT(*) FROM hwlab_usage_records),
(SELECT COUNT(*) FROM hwlab_billing_reservations WHERE status = 'reserved' AND expires_at > now())`).Scan(&totals.Users, &totals.ActiveUsers, &totals.DisabledUsers, &totals.PendingUsers, &totals.BalanceCredits, &totals.ReservedCredits, &totals.AvailableCredits, &totals.UsageCredits, &totals.UsageQuantity, &totals.UsageRecords, &totals.ActiveReservations)
if err != nil {
return nil, err
}
return map[string]any{
"users": totals.Users,
"activeUsers": totals.ActiveUsers,
"disabledUsers": totals.DisabledUsers,
"pendingUsers": totals.PendingUsers,
"balanceCredits": totals.BalanceCredits,
"reservedCredits": totals.ReservedCredits,
"availableCredits": totals.AvailableCredits,
"usageCredits": totals.UsageCredits,
"usageQuantity": totals.UsageQuantity,
"usageRecords": totals.UsageRecords,
"activeReservations": totals.ActiveReservations,
}, nil
}
func queryLimit(r *http.Request, name string, fallback, max int) int {
value := strings.TrimSpace(r.URL.Query().Get(name))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
if parsed > max {
return max
}
return parsed
}
func (s *Server) estimateCredits(explicit, tokens int64) int64 {
if explicit > 0 {
return explicit
}
if tokens <= 0 {
return 1
}
per := s.config.CreditPerThousandTok
if per <= 0 {
per = 1
}
return int64(math.Ceil(float64(tokens*per) / 1000.0))
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
defer r.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid_json", "request body must be valid JSON")
return false
}
return true
}
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{"error": apiError{Code: code, Message: message}})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func bearerToken(r *http.Request) string {
value := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
return strings.TrimSpace(value[7:])
}
return ""
}
func normalizeUsername(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return -1
}, value)
if len(value) < 3 || len(value) > 64 {
return ""
}
return value
}
func hashPassword(password string) (string, error) {
salt, err := randomBytes(16)
if err != nil {
return "", err
}
iterations := 210000
digest := pbkdf2SHA256([]byte(password), salt, iterations, 32)
return fmt.Sprintf("pbkdf2_sha256$%d$%s$%s", iterations, b64(salt), b64(digest)), nil
}
func verifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2_sha256" {
return false
}
iterations, err := strconv.Atoi(parts[1])
if err != nil || iterations <= 0 {
return false
}
salt, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return false
}
expected, err := base64.RawURLEncoding.DecodeString(parts[3])
if err != nil {
return false
}
actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
hLen := sha256.Size
numBlocks := (keyLen + hLen - 1) / hLen
var output []byte
for block := 1; block <= numBlocks; block++ {
u := pbkdf2Block(password, salt, block)
t := append([]byte(nil), u...)
for i := 1; i < iterations; i++ {
u = hmacSHA256(password, u)
for j := range t {
t[j] ^= u[j]
}
}
output = append(output, t...)
}
return output[:keyLen]
}
func pbkdf2Block(password, salt []byte, block int) []byte {
buf := make([]byte, len(salt)+4)
copy(buf, salt)
binary.BigEndian.PutUint32(buf[len(salt):], uint32(block))
return hmacSHA256(password, buf)
}
func hmacSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
_, _ = h.Write(data)
return h.Sum(nil)
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func newID(prefix string) string {
return prefix + "_" + randomToken(16)
}
func randomToken(size int) string {
buf, err := randomBytes(size)
if err != nil {
panic(err)
}
return b64(buf)
}
func randomBytes(size int) ([]byte, error) {
buf := make([]byte, size)
_, err := rand.Read(buf)
return buf, err
}
func b64(value []byte) string {
return base64.RawURLEncoding.EncodeToString(value)
}
func jsonObject(value map[string]any) string {
if value == nil {
return "{}"
}
encoded, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(encoded)
}
func parseInt64(value string, fallback int64) int64 {
if strings.TrimSpace(value) == "" {
return fallback
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fallback
}
return parsed
}
func parseDurationHours(value string, fallbackHours int64) time.Duration {
return time.Duration(parseInt64(value, fallbackHours)) * time.Hour
}
func parseDurationMinutes(value string, fallbackMinutes int64) time.Duration {
return time.Duration(parseInt64(value, fallbackMinutes)) * time.Minute
}
func first(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func nonNegative(value int64) int64 {
if value < 0 {
return 0
}
return value
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func isDuplicate(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "duplicate")
}