diff --git a/docs/reference/spec-v03-user-billing.md b/docs/reference/spec-v03-user-billing.md index 1cff1ea0..608abd7b 100644 --- a/docs/reference/spec-v03-user-billing.md +++ b/docs/reference/spec-v03-user-billing.md @@ -11,6 +11,7 @@ - `POST /internal/auth/introspect`,供 `hwlab-cloud-api` 将 session/API key 归一成 HWLAB actor。 - `POST /internal/billing/preflight` 和 `POST /internal/billing/record`,供 Code Agent、AIPOD、HWPOD 等消耗型操作做余额预检、reservation 和 usage record。 - `GET /internal/admin/billing/summary`、`POST /internal/admin/credits/adjust`、`POST /internal/admin/users/status`,供 admin-only cloud-api 路径代理。 +- `GET /internal/admin/billing/plans`,供 admin-only cloud-api 路径查看 plan、resource entitlement、quota、concurrency 和 RPM 的权威配置。 `hwlab-cloud-api` 仍是浏览器和 AgentRun/HWPOD 等业务入口的应用层边界。它不得直连 user-billing 数据表完成用户状态、余额或 API key 判定;用户态请求必须通过 user-billing introspection 或 admin/internal route 得到结构化结果。 @@ -45,6 +46,10 @@ Cloud Web admin Users page token/credit 计价遵循 reservation/record 分层:消耗型操作先通过 user-billing preflight/reservation 确认余额和计价上下文,再在操作完成或失败时记录 usage/release。Cloud API 可以保存业务 trace、agent session 和运行证据,但不能把这些派生记录当成余额账本。 +plan/entitlement 是 user-billing 的资源授权 authority。`hwlab_credit_accounts.plan_id` 必须指向 PK01 中的 plan;`hwlab_resource_entitlements` 按 `resource_type` 表达服务是否启用、月度 quota、并发 reservation 上限和 RPM 上限。`0` 代表 unlimited,禁用必须通过 `enabled=false` 或 plan 状态表达,不能靠缺行或硬编码默认值猜测。`code_agent`、`aipod`、`hwpod` 是 v0.3 多用户云服务的基础资源类型,新增资源类型必须进入同一 plan/entitlement 模型。 + +`/internal/billing/preflight` 必须在余额预留前执行 entitlement、monthly quota、active reservation concurrency 和 RPM 检查。RPM 与 quota 的权威统计来自 PK01 PostgreSQL reservation/usage 记录;Redis 只能作为后续 cache 或短期加速层,不能成为限流、余额、quota 或授权的唯一来源。preflight 成功后才创建 reservation;running 阶段只保留 reservation,不提前扣费;completed 记录 usage/debit;failed、blocked、timeout、canceled 等未完成状态 release reservation。 + 新增 AIPOD/HWPOD 云托管产品时,产品维度、资源 id、reservation id、usage amount 和状态变迁应进入 user-billing 的 PK01 表结构或迁移;不要在 cloud-api 内新增平行计费表作为第二账本。 ## Secret 与 Bootstrap diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 6fa404f0..612fd737 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2162,6 +2162,8 @@ function redactBillingReservation(value) { if (!reservationId) return null; return pruneEmpty({ reservationId, + resourceType: textOr(reservation.resourceType, ""), + planId: textOr(reservation.planId, ""), estimatedCredits: numberOrNull(reservation.estimatedCredits), estimatedTokens: numberOrNull(reservation.estimatedTokens), expiresAt: textOr(reservation.expiresAt, ""), diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 4ddc0b29..471ca46a 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -842,23 +842,23 @@ async function preflightCodeAgentBilling({ params = {}, options = {}, traceId, r }; const result = await client.billingPreflight(body); if (result.ok && result.body?.allowed !== false) return { reservation: sanitizeBillingReservation(result.body) }; - const status = result.status === 402 ? 402 : 503; + const status = [402, 403, 429].includes(Number(result.status)) ? Number(result.status) : 503; sendJson(response, status, { ok: false, accepted: false, - status: status === 402 ? "payment_required" : "billing_unavailable", + status: status === 402 ? "payment_required" : status === 403 ? "not_entitled" : status === 429 ? "rate_limited" : "billing_unavailable", traceId, error: { code: result.error?.code ?? (status === 402 ? "insufficient_credits" : "user_billing_preflight_failed"), layer: "billing", - retryable: status !== 402, + retryable: status === 429 || status === 503, message: result.error?.message ?? "Code Agent billing preflight failed", route: "/v1/agent/chat" }, blocker: { code: result.error?.code ?? (status === 402 ? "insufficient_credits" : "user_billing_preflight_failed"), layer: "billing", - retryable: status !== 402, + retryable: status === 429 || status === 503, summary: result.error?.message ?? "Code Agent billing preflight failed" }, valuesRedacted: true @@ -986,6 +986,8 @@ function sanitizeBillingReservation(value = {}) { return { allowed: value.allowed === true, reservationId: textValue(value.reservationId) || null, + resourceType: textValue(value.resourceType) || null, + planId: textValue(value.planId) || null, estimatedCredits: Number.isFinite(Number(value.estimatedCredits)) ? Number(value.estimatedCredits) : null, estimatedTokens: Number.isFinite(Number(value.estimatedTokens)) ? Number(value.estimatedTokens) : null, expiresAt: textValue(value.expiresAt) || null, @@ -1861,6 +1863,8 @@ function codeAgentBillingReservationEvidence(value = null) { if (!reservationId) return null; return { reservationId, + resourceType: textValue(value.resourceType) || null, + planId: textValue(value.planId) || null, estimatedCredits: numberOrNull(value.estimatedCredits), estimatedTokens: numberOrNull(value.estimatedTokens), expiresAt: textValue(value.expiresAt) || null, diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index fe89c2fe..1938a58a 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -325,6 +325,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/admin/billing/plans" && request.method === "GET") { + await handleAdminBillingPlansHttp(request, response, options); + return; + } + if (url.pathname === "/v1/admin/billing/users" && (request.method === "GET" || request.method === "POST")) { await handleAdminBillingUsersHttp(request, response, url, options); return; @@ -1121,6 +1126,17 @@ async function handleAdminBillingUsersHttp(request, response, url, options) { sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/admin/billing/users`, route: "/v1/admin/billing/users", actor: adminActorSummary(auth.actor) }); } +async function handleAdminBillingPlansHttp(request, response, options) { + const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/plans"); + if (!context) return; + const { auth, client } = context; + if (typeof client.adminBillingPlans !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin plans API is not configured", { operation: "GET /v1/admin/billing/plans", target: { type: "service", id: "hwlab-user-billing" } }); + } + const result = await client.adminBillingPlans(); + sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/plans", route: "/v1/admin/billing/plans", actor: adminActorSummary(auth.actor) }); +} + async function handleAdminBillingUserHttp(request, response, url, userId, options) { const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/users/{userId}`); if (!context) return; diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts index 78146c89..90c0cd04 100644 --- a/internal/cloud/user-billing-client.ts +++ b/internal/cloud/user-billing-client.ts @@ -119,6 +119,9 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } const query = new URLSearchParams({ limit: String(limit) }); return requestJson(`/internal/admin/billing/summary?${query.toString()}`, { method: "GET" }); }, + async adminBillingPlans() { + return requestJson("/internal/admin/billing/plans", { method: "GET" }); + }, async adminUsers({ page = 1, pageSize = 50, search = "", status = "", role = "" } = {}) { const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); if (text(search)) query.set("search", text(search)); diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts index d556bd2d..94c9cbc8 100644 --- a/internal/cloud/user-billing-integration.test.ts +++ b/internal/cloud/user-billing-integration.test.ts @@ -649,6 +649,105 @@ test("cloud api proxies R1 admin user management and credit adjustment to user-b } }); +test("cloud api proxies R4 admin billing plans and preserves plan updates", async () => { + const calls = []; + const userBillingClient = { + configured: true, + async adminBillingPlans() { + calls.push({ op: "adminBillingPlans" }); + return { + ok: true, + status: 200, + body: { + contractVersion: "user-billing-admin-plans-v1", + plans: [{ plan: { id: "default", displayName: "Default", status: "active" }, entitlements: [{ resourceType: "code_agent", enabled: true, monthlyQuotaUnlimited: true, concurrencyUnlimited: true, rpmUnlimited: true }] }], + count: 1, + valuesRedacted: true + } + }; + }, + async updateAdminUser(userId, body = {}) { + calls.push({ op: "updateAdminUser", userId, body }); + assert.equal(userId, "usr_billing_admin_subject"); + assert.equal(body.planId, "default"); + return { ok: true, status: 200, body: { contractVersion: "user-billing-admin-user-v1", updated: true, detail: { summary: { user: { id: userId }, planId: body.planId } } } }; + } + }; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-password-1127" + }, + userBillingClient + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const login = await fetch(`http://127.0.0.1:${port}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "admin-password-1127" }) + }); + assert.equal(login.status, 200); + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + const plans = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/plans`, { headers: { cookie } }); + assert.equal(plans.status, 200); + const plansBody = await plans.json(); + assert.equal(plansBody.proxy.source, "hwlab-user-billing"); + assert.equal(plansBody.plans[0].entitlements[0].resourceType, "code_agent"); + + const update = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users/usr_billing_admin_subject`, { + method: "PATCH", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ planId: "default" }) + }); + assert.equal(update.status, 200); + assert.equal((await update.json()).detail.summary.planId, "default"); + assert.deepEqual(calls.map((call) => call.op), ["adminBillingPlans", "updateAdminUser"]); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + +test("cloud api maps R4 Code Agent entitlement denial to a user-facing 403", async () => { + const userBillingClient = { + configured: true, + async introspect(token) { + assert.equal(token, "hwl_user_billing_entitlement_denied"); + return { ok: true, status: 200, body: { active: true, principal: { userId: "usr_denied", email: "denied@hwlab.local", username: "denied", role: "user", scopes: ["api"], authType: "api-key", keyId: "key_denied" } } }; + }, + async billingPreflight(body) { + assert.equal(body.serviceId, "hwlab-code-agent"); + return { ok: false, status: 403, error: { code: "resource_not_entitled", message: "resource is not enabled for this billing plan" } }; + } + }; + const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1" }, userBillingClient }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const session = await fetch(`http://127.0.0.1:${port}/v1/agent/sessions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer hwl_user_billing_entitlement_denied" }, + body: JSON.stringify({ conversationId: "cnv_denied", sessionId: "ses_denied", providerProfile: "deepseek" }) + }); + assert.equal(session.status, 201); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer hwl_user_billing_entitlement_denied", "x-trace-id": "trc_user_billing_entitlement_denied", prefer: "respond-async" }, + body: JSON.stringify({ conversationId: "cnv_denied", sessionId: "ses_denied", shortConnection: true, message: "should be blocked by entitlement" }) + }); + assert.equal(response.status, 403); + const payload = await response.json(); + assert.equal(payload.status, "not_entitled"); + assert.equal(payload.error.code, "resource_not_entitled"); + assert.equal(payload.blocker.retryable, false); + assert.equal(JSON.stringify(payload).includes("hwl_user_billing_entitlement_denied"), false); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api proxies R1 user-billing API key and profile self-service routes", async () => { const calls = []; const bearer = "hwl_user_billing_session_secret"; diff --git a/internal/userbilling/migrations/0002_billing_entitlements.sql b/internal/userbilling/migrations/0002_billing_entitlements.sql new file mode 100644 index 00000000..332a0bf8 --- /dev/null +++ b/internal/userbilling/migrations/0002_billing_entitlements.sql @@ -0,0 +1,78 @@ +CREATE TABLE IF NOT EXISTS hwlab_billing_plans ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + description TEXT NOT NULL DEFAULT '', + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (status IN ('active', 'disabled')) +); + +INSERT INTO hwlab_billing_plans (id, display_name, status, description, metadata) +VALUES ('default', 'Default', 'active', 'Default HWLAB v0.3 resource plan', '{"source":"hwlab-v03","limitSemantics":"zero-means-unlimited"}'::jsonb) +ON CONFLICT (id) DO UPDATE SET + display_name = EXCLUDED.display_name, + status = EXCLUDED.status, + description = EXCLUDED.description, + metadata = hwlab_billing_plans.metadata || EXCLUDED.metadata, + updated_at = now(); + +CREATE TABLE IF NOT EXISTS hwlab_resource_entitlements ( + id TEXT PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES hwlab_billing_plans(id) ON DELETE CASCADE, + resource_type TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + monthly_quota_credits BIGINT NOT NULL DEFAULT 0, + max_concurrent_reservations INTEGER NOT NULL DEFAULT 0, + rpm_limit INTEGER NOT NULL DEFAULT 0, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (plan_id, resource_type), + CHECK (resource_type <> ''), + CHECK (monthly_quota_credits >= 0), + CHECK (max_concurrent_reservations >= 0), + CHECK (rpm_limit >= 0) +); + +CREATE INDEX IF NOT EXISTS hwlab_resource_entitlements_plan_idx ON hwlab_resource_entitlements(plan_id); +CREATE INDEX IF NOT EXISTS hwlab_resource_entitlements_resource_idx ON hwlab_resource_entitlements(resource_type); + +INSERT INTO hwlab_resource_entitlements (id, plan_id, resource_type, enabled, monthly_quota_credits, max_concurrent_reservations, rpm_limit, metadata) +VALUES + ('ent_default_code_agent', 'default', 'code_agent', TRUE, 0, 0, 0, '{"serviceIds":["hwlab-code-agent"],"limitSemantics":"zero-means-unlimited"}'::jsonb), + ('ent_default_aipod', 'default', 'aipod', TRUE, 0, 0, 0, '{"serviceIds":["hwlab-aipod"],"limitSemantics":"zero-means-unlimited"}'::jsonb), + ('ent_default_hwpod', 'default', 'hwpod', TRUE, 0, 0, 0, '{"serviceIds":["hwlab-hwpod"],"limitSemantics":"zero-means-unlimited"}'::jsonb) +ON CONFLICT (plan_id, resource_type) DO UPDATE SET + enabled = EXCLUDED.enabled, + monthly_quota_credits = EXCLUDED.monthly_quota_credits, + max_concurrent_reservations = EXCLUDED.max_concurrent_reservations, + rpm_limit = EXCLUDED.rpm_limit, + metadata = hwlab_resource_entitlements.metadata || EXCLUDED.metadata, + updated_at = now(); + +ALTER TABLE hwlab_billing_reservations ADD COLUMN IF NOT EXISTS resource_type TEXT NOT NULL DEFAULT 'generic'; +ALTER TABLE hwlab_usage_records ADD COLUMN IF NOT EXISTS resource_type TEXT NOT NULL DEFAULT 'generic'; + +UPDATE hwlab_billing_reservations +SET resource_type = CASE + WHEN lower(service_id) LIKE '%code-agent%' OR lower(service_id) LIKE '%code_agent%' THEN 'code_agent' + WHEN lower(service_id) LIKE '%aipod%' THEN 'aipod' + WHEN lower(service_id) LIKE '%hwpod%' THEN 'hwpod' + ELSE resource_type +END +WHERE resource_type = 'generic'; + +UPDATE hwlab_usage_records +SET resource_type = CASE + WHEN lower(service_id) LIKE '%code-agent%' OR lower(service_id) LIKE '%code_agent%' THEN 'code_agent' + WHEN lower(service_id) LIKE '%aipod%' THEN 'aipod' + WHEN lower(service_id) LIKE '%hwpod%' THEN 'hwpod' + ELSE resource_type +END +WHERE resource_type = 'generic'; + +CREATE INDEX IF NOT EXISTS hwlab_billing_reservations_user_resource_idx ON hwlab_billing_reservations(user_id, resource_type, status, expires_at); +CREATE INDEX IF NOT EXISTS hwlab_billing_reservations_user_resource_created_idx ON hwlab_billing_reservations(user_id, resource_type, created_at); +CREATE INDEX IF NOT EXISTS hwlab_usage_records_user_resource_created_idx ON hwlab_usage_records(user_id, resource_type, created_at); diff --git a/internal/userbilling/service.go b/internal/userbilling/service.go index 0c09de2e..989ef97c 100644 --- a/internal/userbilling/service.go +++ b/internal/userbilling/service.go @@ -79,11 +79,12 @@ type Principal struct { } type serviceUsageSummary struct { - ServiceID string `json:"serviceId"` - Credits int64 `json:"credits"` - Quantity int64 `json:"quantity"` - RecordCount int64 `json:"recordCount"` - LastUsedAt time.Time `json:"lastUsedAt"` + ServiceID string `json:"serviceId"` + ResourceType string `json:"resourceType"` + Credits int64 `json:"credits"` + Quantity int64 `json:"quantity"` + RecordCount int64 `json:"recordCount"` + LastUsedAt time.Time `json:"lastUsedAt"` } type ledgerSummaryRow struct { @@ -98,6 +99,7 @@ type ledgerSummaryRow struct { type reservationSummaryRow struct { ReservationID string `json:"reservationId"` ServiceID string `json:"serviceId"` + ResourceType string `json:"resourceType"` EstimatedCredits int64 `json:"estimatedCredits"` EstimatedTokens int64 `json:"estimatedTokens"` Status string `json:"status"` @@ -105,13 +107,42 @@ type reservationSummaryRow struct { CreatedAt time.Time `json:"createdAt"` } +type billingPlanSummary struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Status string `json:"status"` + Description string `json:"description"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type resourceEntitlementSummary struct { + ID string `json:"id,omitempty"` + PlanID string `json:"planId,omitempty"` + ResourceType string `json:"resourceType"` + Enabled bool `json:"enabled"` + MonthlyQuotaCredits int64 `json:"monthlyQuotaCredits"` + MonthlyQuotaUnlimited bool `json:"monthlyQuotaUnlimited"` + MonthlyUsageCredits int64 `json:"monthlyUsageCredits"` + MonthlyRemainingCredits int64 `json:"monthlyRemainingCredits"` + MaxConcurrentReservations int64 `json:"maxConcurrentReservations"` + ConcurrencyUnlimited bool `json:"concurrencyUnlimited"` + ActiveReservations int64 `json:"activeReservations"` + RPMLimit int64 `json:"rpmLimit"` + RPMUnlimited bool `json:"rpmUnlimited"` + RPMUsed int64 `json:"rpmUsed"` + LimitSemantics string `json:"limitSemantics"` + Metadata map[string]any `json:"metadata,omitempty"` +} + 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"` + User User `json:"user"` + PlanID string `json:"planId"` + Plan billingPlanSummary `json:"plan,omitempty"` + Entitlements []resourceEntitlementSummary `json:"entitlements,omitempty"` + Credits creditSummary `json:"credits"` + Usage userUsageAggregate `json:"usage"` + APIKeys apiKeyAggregate `json:"apiKeys"` + Reservations reservationAdminSummary `json:"reservations"` } type creditSummary struct { @@ -160,6 +191,11 @@ type adminUserDetail struct { Reservations []reservationSummaryRow `json:"reservations"` } +type billingPlanDetail struct { + Plan billingPlanSummary `json:"plan"` + Entitlements []resourceEntitlementSummary `json:"entitlements"` +} + type reservationAdminSummary struct { ActiveCount int64 `json:"activeCount"` Active []reservationSummaryRow `json:"active"` @@ -286,6 +322,7 @@ func (s *Server) routes() { s.route(http.MethodPost, "/internal/billing/record", s.handleBillingRecord) s.route(http.MethodPost, "/internal/billing/release", s.handleBillingRelease) s.route(http.MethodGet, "/internal/admin/billing/summary", s.handleAdminBillingSummary) + s.route(http.MethodGet, "/internal/admin/billing/plans", s.handleAdminBillingPlans) s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust) s.mux.HandleFunc("/internal/admin/users", s.handleAdminUsers) s.mux.HandleFunc("/internal/admin/users/", s.handleAdminUserByID) @@ -663,6 +700,12 @@ func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) { writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load active reservations") return } + planID, plan, entitlements, err := s.accountPlanContext(ctx, principal.UserID) + if err != nil { + s.logDatabaseError("billing_summary_entitlements", err) + writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load billing plan entitlements") + return + } writeJSON(w, http.StatusOK, map[string]any{ "contractVersion": "user-billing-summary-v1", @@ -674,6 +717,9 @@ func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) { "reserved": reserved, "available": balance - reserved, }, + "planId": planID, + "plan": plan, + "entitlements": entitlements, "usage": map[string]any{ "totalCredits": totalCredits, "totalQuantity": totalQuantity, @@ -903,6 +949,11 @@ func (s *Server) handleBillingPreflight(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusPaymentRequired, map[string]any{"allowed": false, "reason": "insufficient_credits"}) return } + var limitErr billingLimitError + if errors.As(err, &limitErr) { + writeJSON(w, limitErr.Status, map[string]any{"allowed": false, "reason": limitErr.Code, "error": apiError{Code: limitErr.Code, Message: limitErr.Message}}) + return + } writeAPIError(w, http.StatusInternalServerError, "billing_preflight_failed", "could not reserve credits") return } @@ -1114,6 +1165,7 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, u Password *string `json:"password"` Role *string `json:"role"` Status *string `json:"status"` + PlanID *string `json:"planId"` } if !decodeJSON(w, r, &req) { return @@ -1175,7 +1227,15 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, u } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - if len(updates) == 0 { + planID := "" + if req.PlanID != nil { + planID = strings.TrimSpace(*req.PlanID) + if planID == "" { + writeAPIError(w, http.StatusBadRequest, "invalid_plan", "planId is required when updating a billing plan") + return + } + } + if len(updates) == 0 && planID == "" { detail, err := s.adminUserDetail(ctx, userID) if errors.Is(err, sql.ErrNoRows) { writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found") @@ -1188,6 +1248,19 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, u writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": false, "detail": detail, "valuesRedacted": true}) return } + if len(updates) == 0 { + detail, err := s.updateUserPlan(ctx, userID, planID) + if errors.Is(err, sql.ErrNoRows) { + writeAPIError(w, http.StatusNotFound, "user_or_plan_not_found", "user or plan not found") + return + } + if err != nil { + writeAPIError(w, http.StatusBadRequest, "plan_update_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": true, "detail": detail, "valuesRedacted": true}) + return + } query := `UPDATE hwlab_users SET ` + strings.Join(updates, ", ") + `, updated_at = now() WHERE id = $1 RETURNING id, email, username, display_name, status, role, email_verified, created_at` var user User err := s.db.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.DisplayName, &user.Status, &user.Role, &user.EmailVerified, &user.CreatedAt) @@ -1203,6 +1276,12 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, u writeAPIError(w, http.StatusInternalServerError, "user_update_failed", "could not update user") return } + if planID != "" { + if _, err := s.updateUserPlan(ctx, userID, planID); err != nil { + writeAPIError(w, http.StatusBadRequest, "plan_update_failed", err.Error()) + return + } + } writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": true, "user": user, "valuesRedacted": true}) } @@ -1302,8 +1381,31 @@ func (s *Server) handleAdminBillingSummary(w http.ResponseWriter, r *http.Reques }) } +func (s *Server) handleAdminBillingPlans(w http.ResponseWriter, r *http.Request) { + if !s.internalAllowed(w, r) || !s.databaseAvailable(w) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + plans, err := s.billingPlans(ctx) + if err != nil { + s.logDatabaseError("admin_billing_plans", err) + writeAPIError(w, http.StatusInternalServerError, "admin_billing_plans_failed", "could not load billing plans") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-plans-v1", "plans": plans, "count": len(plans), "limitSemantics": "zero-means-unlimited", "valuesRedacted": true}) +} + var errInsufficientCredits = errors.New("insufficient credits") +type billingLimitError struct { + Code string + Message string + Status int +} + +func (err billingLimitError) Error() string { return err.Message } + 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 { @@ -1313,16 +1415,23 @@ func (s *Server) reserveCredits(ctx context.Context, userID, service string, cre 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) + var resourceType string + err := tx.QueryRowContext(ctx, `SELECT id, status, estimated_credits, resource_type FROM hwlab_billing_reservations WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID, &status, &estimated, &resourceType) if err == nil { - return map[string]any{"allowed": status == "reserved", "reservationId": existingID, "estimatedCredits": estimated, "status": status, "idempotent": true}, tx.Commit() + return map[string]any{"allowed": status == "reserved", "reservationId": existingID, "estimatedCredits": estimated, "resourceType": resourceType, "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 { + var planID string + if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits, plan_id FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &reserved, &planID); err != nil { + return nil, err + } + resourceType := resourceTypeForService(service) + entitlement, err := s.evaluateResourceEntitlement(ctx, tx, userID, planID, resourceType, credits) + if err != nil { return nil, err } available := balance - reserved @@ -1332,7 +1441,7 @@ func (s *Server) reserveCredits(ctx context.Context, userID, service string, cre 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) + _, err = tx.ExecContext(ctx, `INSERT INTO hwlab_billing_reservations (id, user_id, service_id, resource_type, estimated_credits, estimated_tokens, idempotency_key, metadata, expires_at) VALUES ($1, $2, $3, $4, $5, $6, nullif($7, ''), $8::jsonb, $9)`, reservationID, userID, service, resourceType, credits, nonNegative(tokens), idempotencyKey, metadataJSON, expiresAt) if err != nil { return nil, err } @@ -1343,7 +1452,7 @@ func (s *Server) reserveCredits(ctx context.Context, userID, service string, cre 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 + return map[string]any{"allowed": true, "reservationId": reservationID, "estimatedCredits": credits, "estimatedTokens": tokens, "resourceType": resourceType, "planId": planID, "entitlement": entitlement, "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) { @@ -1363,9 +1472,10 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID } } var reserved int64 + resourceType := "" 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) + err := tx.QueryRowContext(ctx, `SELECT user_id, service_id, resource_type, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &serviceID, &resourceType, &reserved, &status) if err != nil { return nil, err } @@ -1379,15 +1489,25 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID } userID = principal.UserID serviceID = strings.TrimSpace(serviceID) + resourceType = resourceTypeForService(serviceID) } if serviceID == "" { return nil, errors.New("serviceId is required") } + if resourceType == "" { + resourceType = resourceTypeForService(serviceID) + } 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 { + var planID string + if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits, plan_id FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &accountReserved, &planID); err != nil { return nil, err } + if reservationID == "" { + if _, err := s.evaluateResourceEntitlement(ctx, tx, userID, planID, resourceType, credits); err != nil { + return nil, err + } + } if reservationID == "" && balance-accountReserved < credits { return nil, errInsufficientCredits } @@ -1405,7 +1525,7 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID } 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) + _, err = tx.ExecContext(ctx, `INSERT INTO hwlab_usage_records (id, reservation_id, user_id, service_id, resource_type, quantity, credits, idempotency_key, metadata) VALUES ($1, nullif($2, ''), $3, $4, $5, $6, $7, nullif($8, ''), $9::jsonb)`, recordID, reservationID, userID, serviceID, resourceType, nonNegative(usedTokens), credits, idempotencyKey, metadataJSON) if err != nil { return nil, err } @@ -1426,7 +1546,7 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID if err := tx.Commit(); err != nil { return nil, err } - return map[string]any{"recordId": recordID, "userId": userID, "serviceId": serviceID, "credits": credits, "balance": newBalance}, nil + return map[string]any{"recordId": recordID, "userId": userID, "serviceId": serviceID, "resourceType": resourceType, "credits": credits, "balance": newBalance}, nil } func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceID, reason, idempotencyKey string, metadata map[string]any) (map[string]any, error) { @@ -1439,9 +1559,9 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI return nil, err } defer tx.Rollback() - var userID, actualServiceID, status string + var userID, actualServiceID, resourceType, status string var reserved int64 - err = tx.QueryRowContext(ctx, `SELECT user_id, service_id, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &actualServiceID, &reserved, &status) + err = tx.QueryRowContext(ctx, `SELECT user_id, service_id, resource_type, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &actualServiceID, &resourceType, &reserved, &status) if err != nil { return nil, err } @@ -1450,7 +1570,7 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI return nil, fmt.Errorf("reservation service is %s", actualServiceID) } if status == "cancelled" || status == "expired" { - return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "releasedCredits": int64(0), "status": status, "idempotent": true}, tx.Commit() + return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "resourceType": resourceType, "releasedCredits": int64(0), "status": status, "idempotent": true}, tx.Commit() } if status != "reserved" { return nil, fmt.Errorf("reservation status is %s", status) @@ -1468,8 +1588,8 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI return nil, err } releaseMetadata := map[string]any{ - "releaseReason": strings.TrimSpace(reason), - "releaseIdempotency": strings.TrimSpace(idempotencyKey), + "releaseReason": strings.TrimSpace(reason), + "releaseIdempotency": strings.TrimSpace(idempotencyKey), "releaseValuesRedacted": true, } for key, value := range metadata { @@ -1482,7 +1602,7 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI if err := tx.Commit(); err != nil { return nil, err } - return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "releasedCredits": reserved, "status": "cancelled"}, nil + return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "resourceType": resourceType, "releasedCredits": reserved, "status": "cancelled"}, nil } func (s *Server) adjustCredits(ctx context.Context, userID string, delta int64, kind, reason, idempotencyKey string, metadata map[string]any) (int64, error) { @@ -1520,6 +1640,201 @@ func (s *Server) adjustCredits(ctx context.Context, userID string, delta int64, return newBalance, tx.Commit() } +type txQueryer interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func (s *Server) evaluateResourceEntitlement(ctx context.Context, tx txQueryer, userID, planID, resourceType string, requestedCredits int64) (resourceEntitlementSummary, error) { + plan, err := s.planByID(ctx, tx, planID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return resourceEntitlementSummary{}, billingLimitError{Code: "plan_not_found", Message: "billing plan is not active", Status: http.StatusForbidden} + } + return resourceEntitlementSummary{}, err + } + if plan.Status != "active" { + return resourceEntitlementSummary{}, billingLimitError{Code: "plan_disabled", Message: "billing plan is disabled", Status: http.StatusForbidden} + } + entitlements, err := s.resourceEntitlements(ctx, tx, planID, userID) + if err != nil { + return resourceEntitlementSummary{}, err + } + var entitlement resourceEntitlementSummary + found := false + for _, item := range entitlements { + if item.ResourceType == resourceType { + entitlement = item + found = true + break + } + } + if !found || !entitlement.Enabled { + return resourceEntitlementSummary{}, billingLimitError{Code: "resource_not_entitled", Message: "resource is not enabled for this billing plan", Status: http.StatusForbidden} + } + if entitlement.MaxConcurrentReservations > 0 && entitlement.ActiveReservations >= entitlement.MaxConcurrentReservations { + return entitlement, billingLimitError{Code: "resource_concurrency_exceeded", Message: "resource concurrency limit exceeded", Status: http.StatusTooManyRequests} + } + if entitlement.RPMLimit > 0 && entitlement.RPMUsed >= entitlement.RPMLimit { + return entitlement, billingLimitError{Code: "resource_rpm_exceeded", Message: "resource RPM limit exceeded", Status: http.StatusTooManyRequests} + } + if entitlement.MonthlyQuotaCredits > 0 && entitlement.MonthlyUsageCredits+requestedCredits > entitlement.MonthlyQuotaCredits { + return entitlement, billingLimitError{Code: "resource_monthly_quota_exceeded", Message: "resource monthly quota exceeded", Status: http.StatusPaymentRequired} + } + return entitlement, nil +} + +func (s *Server) planByID(ctx context.Context, queryer txQueryer, planID string) (billingPlanSummary, error) { + planID = strings.TrimSpace(planID) + if planID == "" { + planID = "default" + } + var plan billingPlanSummary + var metadataRaw string + err := queryer.QueryRowContext(ctx, `SELECT id, display_name, status, description, metadata::text FROM hwlab_billing_plans WHERE id = $1`, planID).Scan(&plan.ID, &plan.DisplayName, &plan.Status, &plan.Description, &metadataRaw) + if err != nil { + return plan, err + } + plan.Metadata = parseJSONMap(metadataRaw) + return plan, nil +} + +func (s *Server) billingPlans(ctx context.Context) ([]billingPlanDetail, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, display_name, status, description, metadata::text FROM hwlab_billing_plans ORDER BY id ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + plans := []billingPlanDetail{} + for rows.Next() { + var detail billingPlanDetail + var metadataRaw string + if err := rows.Scan(&detail.Plan.ID, &detail.Plan.DisplayName, &detail.Plan.Status, &detail.Plan.Description, &metadataRaw); err != nil { + return nil, err + } + detail.Plan.Metadata = parseJSONMap(metadataRaw) + plans = append(plans, detail) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := range plans { + entitlements, err := s.resourceEntitlements(ctx, s.db, plans[i].Plan.ID, "") + if err != nil { + return nil, err + } + plans[i].Entitlements = entitlements + } + return plans, nil +} + +func (s *Server) updateUserPlan(ctx context.Context, userID, planID string) (adminUserDetail, error) { + planID = strings.TrimSpace(planID) + if planID == "" { + return adminUserDetail{}, errors.New("planId is required") + } + if _, err := s.planByID(ctx, s.db, planID); err != nil { + return adminUserDetail{}, err + } + res, err := s.db.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET plan_id = $2, updated_at = now() WHERE user_id = $1`, userID, planID) + if err != nil { + return adminUserDetail{}, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return adminUserDetail{}, sql.ErrNoRows + } + return s.adminUserDetail(ctx, userID) +} + +func (s *Server) resourceEntitlements(ctx context.Context, queryer txQueryer, planID, userID string) ([]resourceEntitlementSummary, error) { + rows, err := queryer.QueryContext(ctx, ` +SELECT id, plan_id, resource_type, enabled, monthly_quota_credits, max_concurrent_reservations, rpm_limit, metadata::text +FROM hwlab_resource_entitlements +WHERE plan_id = $1 +ORDER BY resource_type ASC`, planID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []resourceEntitlementSummary{} + for rows.Next() { + var item resourceEntitlementSummary + var metadataRaw string + if err := rows.Scan(&item.ID, &item.PlanID, &item.ResourceType, &item.Enabled, &item.MonthlyQuotaCredits, &item.MaxConcurrentReservations, &item.RPMLimit, &metadataRaw); err != nil { + return nil, err + } + item.Metadata = parseJSONMap(metadataRaw) + item.LimitSemantics = "zero-means-unlimited" + item.MonthlyQuotaUnlimited = item.MonthlyQuotaCredits == 0 + item.ConcurrencyUnlimited = item.MaxConcurrentReservations == 0 + item.RPMUnlimited = item.RPMLimit == 0 + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + if strings.TrimSpace(userID) == "" || len(items) == 0 { + return items, nil + } + for i := range items { + if err := s.populateEntitlementUsage(ctx, queryer, userID, &items[i]); err != nil { + return nil, err + } + } + return items, nil +} + +func (s *Server) populateEntitlementUsage(ctx context.Context, queryer txQueryer, userID string, item *resourceEntitlementSummary) error { + monthStart := time.Now().UTC().Format("2006-01-02") + if err := queryer.QueryRowContext(ctx, `SELECT COALESCE(SUM(credits), 0) FROM hwlab_usage_records WHERE user_id = $1 AND resource_type = $2 AND created_at >= date_trunc('month', $3::timestamptz)`, userID, item.ResourceType, monthStart).Scan(&item.MonthlyUsageCredits); err != nil { + return err + } + if err := queryer.QueryRowContext(ctx, `SELECT COUNT(*) FROM hwlab_billing_reservations WHERE user_id = $1 AND resource_type = $2 AND status = 'reserved' AND expires_at > now()`, userID, item.ResourceType).Scan(&item.ActiveReservations); err != nil { + return err + } + if err := queryer.QueryRowContext(ctx, `SELECT COUNT(*) FROM hwlab_billing_reservations WHERE user_id = $1 AND resource_type = $2 AND created_at >= now() - interval '1 minute'`, userID, item.ResourceType).Scan(&item.RPMUsed); err != nil { + return err + } + if item.MonthlyQuotaCredits > 0 { + item.MonthlyRemainingCredits = item.MonthlyQuotaCredits - item.MonthlyUsageCredits + if item.MonthlyRemainingCredits < 0 { + item.MonthlyRemainingCredits = 0 + } + } + return nil +} + +func resourceTypeForService(serviceID string) string { + value := strings.ToLower(strings.TrimSpace(serviceID)) + switch { + case strings.Contains(value, "code-agent") || strings.Contains(value, "code_agent") || strings.Contains(value, "codex"): + return "code_agent" + case strings.Contains(value, "aipod") || strings.Contains(value, "ai-pod"): + return "aipod" + case strings.Contains(value, "hwpod") || strings.Contains(value, "hw-pod"): + return "hwpod" + case value != "": + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' { + return r + } + if r == '-' || r == '.' || r == '/' { + return '_' + } + return -1 + }, value) + default: + return "generic" + } +} + +func parseJSONMap(raw string) map[string]any { + result := map[string]any{} + _ = json.Unmarshal([]byte(raw), &result) + return result +} + func (s *Server) principalForBilling(ctx context.Context, r *http.Request, token, apiKey, userID string) (*Principal, error) { if token == "" && apiKey == "" { token = bearerToken(r) @@ -1684,6 +1999,19 @@ func (s *Server) accountBalance(ctx context.Context, userID string) (int64, int6 return balance, reserved, err } +func (s *Server) accountPlanContext(ctx context.Context, userID string) (string, billingPlanSummary, []resourceEntitlementSummary, error) { + planID := "default" + if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(plan_id, 'default') FROM hwlab_credit_accounts WHERE user_id = $1`, userID).Scan(&planID); err != nil { + return "", billingPlanSummary{}, nil, err + } + plan, err := s.planByID(ctx, s.db, planID) + if err != nil { + return planID, billingPlanSummary{}, nil, err + } + entitlements, err := s.resourceEntitlements(ctx, s.db, planID, userID) + return planID, plan, entitlements, 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) @@ -1691,7 +2019,7 @@ func (s *Server) usageTotals(ctx context.Context, userID string) (int64, int64, } 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) + rows, err := s.db.QueryContext(ctx, `SELECT service_id, resource_type, COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*), MAX(created_at) FROM hwlab_usage_records WHERE user_id = $1 GROUP BY service_id, resource_type ORDER BY MAX(created_at) DESC NULLS LAST, service_id ASC LIMIT $2`, userID, limit) if err != nil { return nil, err } @@ -1699,7 +2027,7 @@ func (s *Server) usageByService(ctx context.Context, userID string, limit int) ( items := []serviceUsageSummary{} for rows.Next() { var item serviceUsageSummary - if err := rows.Scan(&item.ServiceID, &item.Credits, &item.Quantity, &item.RecordCount, &item.LastUsedAt); err != nil { + if err := rows.Scan(&item.ServiceID, &item.ResourceType, &item.Credits, &item.Quantity, &item.RecordCount, &item.LastUsedAt); err != nil { return nil, err } items = append(items, item) @@ -1725,7 +2053,7 @@ func (s *Server) ledgerRows(ctx context.Context, userID string, limit int) ([]le } 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) + rows, err := s.db.QueryContext(ctx, `SELECT id, service_id, resource_type, 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 } @@ -1733,7 +2061,7 @@ func (s *Server) activeReservations(ctx context.Context, userID string, limit in 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 { + if err := rows.Scan(&item.ReservationID, &item.ServiceID, &item.ResourceType, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil { return nil, err } items = append(items, item) @@ -1819,6 +2147,9 @@ LIMIT $1`, limit) for i := range users { users[i].Reservations.Active = reservations[users[i].User.ID] } + if err := s.enrichAdminBillingUsers(ctx, users); err != nil { + return nil, err + } return users, nil } @@ -1836,9 +2167,9 @@ func (s *Server) activeReservationsForUsers(ctx context.Context, userIDs []strin 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 +SELECT user_id, id, service_id, resource_type, 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, + SELECT user_id, id, service_id, resource_type, 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() @@ -1853,7 +2184,7 @@ ORDER BY expires_at ASC, created_at DESC` 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 { + if err := rows.Scan(&userID, &item.ReservationID, &item.ServiceID, &item.ResourceType, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil { return nil, err } items[userID] = append(items[userID], item) @@ -2036,9 +2367,28 @@ LIMIT `+limitParam+` OFFSET `+offsetParam, args...) for i := range users { users[i].Reservations.Active = reservations[users[i].User.ID] } + if err := s.enrichAdminBillingUsers(ctx, users); err != nil { + return nil, 0, err + } return users, total, nil } +func (s *Server) enrichAdminBillingUsers(ctx context.Context, users []adminBillingUserSummary) error { + for i := range users { + plan, err := s.planByID(ctx, s.db, users[i].PlanID) + if err != nil { + return err + } + entitlements, err := s.resourceEntitlements(ctx, s.db, users[i].PlanID, users[i].User.ID) + if err != nil { + return err + } + users[i].Plan = plan + users[i].Entitlements = entitlements + } + return nil +} + func adminUsersWhere(query adminUsersQuery) (string, []any) { clauses := []string{} args := []any{} diff --git a/web/hwlab-cloud-web/src/api/usage.ts b/web/hwlab-cloud-web/src/api/usage.ts index 8088a1cf..6a05fbca 100644 --- a/web/hwlab-cloud-web/src/api/usage.ts +++ b/web/hwlab-cloud-web/src/api/usage.ts @@ -1,9 +1,10 @@ import { fetchJson } from "./client"; -import type { AccessUserStatus, AdminBillingMutationResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, ApiResult, UsageSummary } from "@/types"; +import type { AccessUserStatus, AdminBillingMutationResponse, AdminBillingPlansResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, ApiResult, UsageSummary } from "@/types"; export const usageAPI = { summary: (): Promise> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }), adminBillingSummary: (): Promise> => fetchJson("/v1/admin/billing/summary", { timeoutMs: 12000, timeoutName: "admin billing summary" }), + adminPlans: (): Promise> => fetchJson("/v1/admin/billing/plans", { timeoutMs: 12000, timeoutName: "admin billing plans" }), adminUsers: (params: { page?: number; pageSize?: number; search?: string; status?: string; role?: string } = {}): Promise> => { const query = new URLSearchParams({ page: String(params.page ?? 1), pageSize: String(params.pageSize ?? 25) }); if (params.search) query.set("search", params.search); diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index ab74ee58..8445d4af 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -320,14 +320,37 @@ export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: u export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array>; [key: string]: unknown } export interface AdminAccessMutationResponse { ok?: boolean; user?: AuthUser; [key: string]: unknown } export interface ApiKeyRecord { id: string; name?: string; prefix?: string; createdAt?: string; lastUsedAt?: string | null; revokedAt?: string | null; displaySecret?: string | null } -export interface UsageServiceSummary { serviceId: string; credits?: number; quantity?: number; recordCount?: number; lastUsedAt?: string } +export interface UsageServiceSummary { serviceId: string; resourceType?: string; credits?: number; quantity?: number; recordCount?: number; lastUsedAt?: string } export interface UsageLedgerRow { id: string; kind?: string; reason?: string; deltaCredits?: number; balanceAfter?: number; createdAt?: string } -export interface UsageReservationRow { reservationId: string; serviceId?: string; estimatedCredits?: number; estimatedTokens?: number; status?: string; expiresAt?: string; createdAt?: string } +export interface UsageReservationRow { reservationId: string; serviceId?: string; resourceType?: string; estimatedCredits?: number; estimatedTokens?: number; status?: string; expiresAt?: string; createdAt?: string } +export interface BillingPlanSummary { id?: string; displayName?: string; status?: string; description?: string; metadata?: Record } +export interface ResourceEntitlementSummary { + id?: string; + planId?: string; + resourceType?: string; + enabled?: boolean; + monthlyQuotaCredits?: number; + monthlyQuotaUnlimited?: boolean; + monthlyUsageCredits?: number; + monthlyRemainingCredits?: number; + maxConcurrentReservations?: number; + concurrencyUnlimited?: boolean; + activeReservations?: number; + rpmLimit?: number; + rpmUnlimited?: boolean; + rpmUsed?: number; + limitSemantics?: string; + metadata?: Record; +} +export interface BillingPlanDetail { plan?: BillingPlanSummary; entitlements?: ResourceEntitlementSummary[] } export interface UsageSummary { status?: string; contractVersion?: string; rows?: Array>; credits?: { balance?: number; reserved?: number; available?: number }; + planId?: string; + plan?: BillingPlanSummary; + entitlements?: ResourceEntitlementSummary[]; usage?: { totalCredits?: number; totalQuantity?: number; recordCount?: number; byService?: UsageServiceSummary[] }; ledger?: { count?: number; limit?: number; rows?: UsageLedgerRow[] }; reservations?: { activeCount?: number; active?: UsageReservationRow[] }; @@ -340,6 +363,8 @@ export interface UsageSummary { export interface AdminBillingUserSummary { user?: AuthUser & { email?: string; emailVerified?: boolean; createdAt?: string }; planId?: string; + plan?: BillingPlanSummary; + entitlements?: ResourceEntitlementSummary[]; credits?: { balance?: number; reserved?: number; available?: number }; usage?: { totalCredits?: number; totalQuantity?: number; recordCount?: number; lastUsedAt?: string | null }; apiKeys?: { activeCount?: number; totalCount?: number; lastUsedAt?: string | null }; @@ -372,6 +397,16 @@ export interface AdminBillingUsersResponse { [key: string]: unknown; } +export interface AdminBillingPlansResponse { + status?: string; + contractVersion?: string; + count?: number; + plans?: BillingPlanDetail[]; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + export interface AdminBillingUserDetail { summary?: AdminBillingUserSummary; ledger?: { count?: number; rows?: UsageLedgerRow[] }; diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.vue b/web/hwlab-cloud-web/src/views/admin/UsersView.vue index 3409f011..781d9aa2 100644 --- a/web/hwlab-cloud-web/src/views/admin/UsersView.vue +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.vue @@ -3,9 +3,10 @@ import { computed, onMounted, reactive, ref } from "vue"; import { usageAPI } from "@/api"; import EmptyState from "@/components/common/EmptyState.vue"; import PageHeader from "@/components/common/PageHeader.vue"; -import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AuthUser } from "@/types"; +import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AuthUser, BillingPlanDetail, ResourceEntitlementSummary } from "@/types"; const users = ref([]); +const plans = ref([]); const detail = ref(null); const selectedUserId = ref(null); const loading = ref(true); @@ -20,17 +21,26 @@ const stateAuthority = ref("pk01-postgres"); const filters = reactive({ search: "", status: "", role: "" }); const createForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", initialCredits: 0 }); -const editForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active" }); +const editForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", planId: "default" }); const creditForm = reactive({ deltaCredits: 0, reason: "admin_adjust", idempotencyKey: "" }); const selectedSummary = computed(() => detail.value?.summary ?? users.value.find((item) => item.user?.id === selectedUserId.value) ?? null); const selectedUser = computed(() => selectedSummary.value?.user ?? null); +const selectedEntitlements = computed(() => selectedSummary.value?.entitlements ?? []); const ledgerRows = computed(() => detail.value?.ledger?.rows ?? []); const apiKeys = computed(() => detail.value?.apiKeys?.keys ?? []); const reservations = computed(() => detail.value?.reservations?.rows ?? detail.value?.reservations?.active ?? selectedSummary.value?.reservations?.active ?? []); const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))); -onMounted(refresh); +onMounted(async () => { + await loadPlans(); + await refresh(); +}); + +async function loadPlans(): Promise { + const response = await usageAPI.adminPlans(); + if (response.ok) plans.value = response.data?.plans ?? []; +} async function refresh(): Promise { loading.value = true; @@ -73,6 +83,7 @@ function fillEditForm(user: (AuthUser & { email?: string }) | null | undefined): editForm.password = ""; editForm.role = user?.role === "admin" ? "admin" : "user"; editForm.status = String(user?.status ?? "active"); + editForm.planId = selectedSummary.value?.planId ?? "default"; } async function createUser(): Promise { @@ -104,7 +115,8 @@ async function saveUser(): Promise { username: editForm.username.trim(), displayName: editForm.displayName.trim(), role: editForm.role, - status: editForm.status + status: editForm.status, + planId: editForm.planId || "default" }; if (editForm.password) body.password = editForm.password; const response = await usageAPI.updateAdminUser(userId, body); @@ -182,6 +194,10 @@ function formatDate(value: unknown): string { if (Number.isNaN(date.getTime())) return text; return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } + +function entitlementLimit(current: unknown, limit: unknown, unlimited: unknown): string { + return `${formatNumber(current)} / ${unlimited ? "unlimited" : formatNumber(limit)}`; +}