diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 966745af..1393184e 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -320,6 +320,21 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/redeem" && request.method === "POST") { + await handleRedeemHttp(request, response, options); + return; + } + + if (url.pathname === "/v1/subscription/summary" && request.method === "GET") { + await handleSubscriptionSummaryHttp(request, response, options); + return; + } + + if (url.pathname === "/v1/payments/summary" && request.method === "GET") { + await handlePaymentSummaryHttp(request, response, options); + return; + } + if (url.pathname === "/v1/admin/billing/summary" && request.method === "GET") { await handleAdminBillingSummaryHttp(request, response, url, options); return; @@ -350,6 +365,22 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/admin/billing/redeem-codes" && (request.method === "GET" || request.method === "POST")) { + await handleAdminBillingRedeemCodesHttp(request, response, url, options); + return; + } + + const adminBillingRedeemCodeMatch = url.pathname.match(/^\/v1\/admin\/billing\/redeem-codes\/([^/]+)$/u); + if (adminBillingRedeemCodeMatch && request.method === "PATCH") { + await handleAdminBillingRedeemCodeHttp(request, response, decodeURIComponent(adminBillingRedeemCodeMatch[1]), options); + return; + } + + if (url.pathname === "/v1/admin/billing/redeem-redemptions" && request.method === "GET") { + await handleAdminBillingRedeemRedemptionsHttp(request, response, url, options); + return; + } + if (url.pathname === "/v1/admin/billing/users" && (request.method === "GET" || request.method === "POST")) { await handleAdminBillingUsersHttp(request, response, url, options); return; @@ -1119,6 +1150,64 @@ async function requireAdminBillingClient(request, response, options, operation) return { auth, client }; } +async function requireUserBillingClient(request, response, options, operation) { + const nextOptions = await codeAgentOptions(request, response, options, { required: true }); + if (!nextOptions) return null; + const client = nextOptions.userBillingClient; + const token = userBillingTokenFromRequest(request); + if (nextOptions.userBillingAuth?.active !== true || !token) { + sendRestError(request, response, 403, "user_billing_auth_required", "user-billing session or API key is required", { + operation, + target: { type: "route", id: operation }, + result: "rejected" + }); + return null; + } + if (!client?.configured) { + sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing API is not configured", { + operation, + target: { type: "service", id: "hwlab-user-billing" } + }); + return null; + } + return { auth: nextOptions, client, token }; +} + +async function handleRedeemHttp(request, response, options) { + const context = await requireUserBillingClient(request, response, options, "POST /v1/redeem"); + if (!context) return; + const { auth, client, token } = context; + if (typeof client.redeem !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing redeem API is not configured", { operation: "POST /v1/redeem", target: { type: "service", id: "hwlab-user-billing" } }); + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + const result = await client.redeem(token, parsed.value); + sendUserBillingProxyResult({ request, response, result, operation: "POST /v1/redeem", route: "/v1/redeem", actor: adminActorSummary(auth.actor) }); +} + +async function handleSubscriptionSummaryHttp(request, response, options) { + const context = await requireUserBillingClient(request, response, options, "GET /v1/subscription/summary"); + if (!context) return; + const { auth, client, token } = context; + if (typeof client.subscriptionSummary !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing subscription API is not configured", { operation: "GET /v1/subscription/summary", target: { type: "service", id: "hwlab-user-billing" } }); + } + const result = await client.subscriptionSummary(token); + sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/subscription/summary", route: "/v1/subscription/summary", actor: adminActorSummary(auth.actor) }); +} + +async function handlePaymentSummaryHttp(request, response, options) { + const context = await requireUserBillingClient(request, response, options, "GET /v1/payments/summary"); + if (!context) return; + const { auth, client, token } = context; + if (typeof client.paymentSummary !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing payment API is not configured", { operation: "GET /v1/payments/summary", target: { type: "service", id: "hwlab-user-billing" } }); + } + const result = await client.paymentSummary(token); + sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/payments/summary", route: "/v1/payments/summary", actor: adminActorSummary(auth.actor) }); +} + async function handleAdminBillingUsersHttp(request, response, url, options) { const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/users`); if (!context) return; @@ -1201,6 +1290,56 @@ async function handleAdminBillingLedgerExportHttp(request, response, url, option sendUserBillingCsvProxyResult({ request, response, result, operation: "GET /v1/admin/billing/ledger/export", filename: "hwlab-admin-ledger.csv" }); } +async function handleAdminBillingRedeemCodesHttp(request, response, url, options) { + const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/redeem-codes`); + if (!context) return; + const { auth, client } = context; + let result; + if (request.method === "GET") { + if (typeof client.adminRedeemCodes !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin redeem code API is not configured", { operation: "GET /v1/admin/billing/redeem-codes", target: { type: "service", id: "hwlab-user-billing" } }); + } + result = await client.adminRedeemCodes(adminRedeemParams(url)); + } else { + if (typeof client.createAdminRedeemCode !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin redeem code create API is not configured", { operation: "POST /v1/admin/billing/redeem-codes", target: { type: "service", id: "hwlab-user-billing" } }); + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + result = await client.createAdminRedeemCode({ + ...parsed.value, + createdByUserId: String(auth.actor?.id ?? ""), + createdByUsername: String(auth.actor?.username ?? ""), + metadata: { ...(parsed.value?.metadata && typeof parsed.value.metadata === "object" && !Array.isArray(parsed.value.metadata) ? parsed.value.metadata : {}), operator: adminActorSummary(auth.actor) } + }); + } + sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/admin/billing/redeem-codes`, route: "/v1/admin/billing/redeem-codes", actor: adminActorSummary(auth.actor) }); +} + +async function handleAdminBillingRedeemCodeHttp(request, response, codeId, options) { + const context = await requireAdminBillingClient(request, response, options, "PATCH /v1/admin/billing/redeem-codes/{codeId}"); + if (!context) return; + const { auth, client } = context; + if (typeof client.updateAdminRedeemCode !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin redeem code update API is not configured", { operation: "PATCH /v1/admin/billing/redeem-codes/{codeId}", target: { type: "service", id: "hwlab-user-billing" } }); + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + const result = await client.updateAdminRedeemCode(codeId, parsed.value); + sendUserBillingProxyResult({ request, response, result, operation: "PATCH /v1/admin/billing/redeem-codes/{codeId}", route: "/v1/admin/billing/redeem-codes/{codeId}", actor: adminActorSummary(auth.actor) }); +} + +async function handleAdminBillingRedeemRedemptionsHttp(request, response, url, options) { + const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/redeem-redemptions"); + if (!context) return; + const { auth, client } = context; + if (typeof client.adminRedeemRedemptions !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin redeem redemptions API is not configured", { operation: "GET /v1/admin/billing/redeem-redemptions", target: { type: "service", id: "hwlab-user-billing" } }); + } + const result = await client.adminRedeemRedemptions(adminRedeemParams(url)); + sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/redeem-redemptions", route: "/v1/admin/billing/redeem-redemptions", 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; @@ -1425,6 +1564,15 @@ function adminAuditParams(url) { return params; } +function adminRedeemParams(url) { + const params = {}; + for (const key of ["limit", "status", "userId", "codeId"]) { + const value = String(url.searchParams.get(key) ?? "").trim(); + if (value) params[key] = value; + } + return params; +} + function adminActorSummary(actor) { return actor ? { id: String(actor.id ?? ""), diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts index 04d46a14..d383a69b 100644 --- a/internal/cloud/user-billing-client.ts +++ b/internal/cloud/user-billing-client.ts @@ -115,6 +115,15 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } const query = new URLSearchParams({ limit: String(limit) }); return requestJson(`/v1/billing/summary?${query.toString()}`, { method: "GET", bearerToken: token }); }, + async redeem(token, body = {}) { + return requestJson("/v1/redeem", { method: "POST", body, bearerToken: token }); + }, + async subscriptionSummary(token) { + return requestJson("/v1/subscription/summary", { method: "GET", bearerToken: token }); + }, + async paymentSummary(token) { + return requestJson("/v1/payments/summary", { method: "GET", bearerToken: token }); + }, async updateProfile(token, body = {}) { return patch("/v1/me/profile", body, { bearerToken: token }); }, @@ -152,6 +161,18 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } async adminLedgerExport(params = {}) { return requestText(adminAuditPath("/internal/admin/ledger/export", params), { method: "GET" }); }, + async adminRedeemCodes(params = {}) { + return requestJson(adminRedeemPath("/internal/admin/redeem-codes", params), { method: "GET" }); + }, + async createAdminRedeemCode(body = {}) { + return post("/internal/admin/redeem-codes", body); + }, + async updateAdminRedeemCode(codeId, body = {}) { + return patch(`/internal/admin/redeem-codes/${encodeURIComponent(text(codeId))}`, body); + }, + async adminRedeemRedemptions(params = {}) { + return requestJson(adminRedeemPath("/internal/admin/redeem-redemptions", params), { 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)); @@ -187,6 +208,16 @@ function adminAuditPath(path, params = {}) { return suffix ? `${path}?${suffix}` : path; } +function adminRedeemPath(path, params = {}) { + const query = new URLSearchParams(); + for (const key of ["limit", "status", "userId", "codeId"]) { + const value = text(params?.[key]); + if (value) query.set(key, value); + } + const suffix = query.toString(); + return suffix ? `${path}?${suffix}` : path; +} + function normalizeBaseUrl(value) { const input = text(value); if (!input) return ""; diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts index 5695e872..1950628d 100644 --- a/internal/cloud/user-billing-integration.test.ts +++ b/internal/cloud/user-billing-integration.test.ts @@ -959,3 +959,101 @@ test("cloud api reports auth_required before user-billing profile/password proxy await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } }); + +test("cloud api proxies R6 redeem subscription payment and admin redeem routes", async () => { + const calls = []; + const sessionToken = "hws_r6_redeem_session_secret"; + const userBillingClient = { + configured: true, + async login(body = {}) { + calls.push({ op: "login", username: body.username }); + assert.equal(body.username, "admin"); + assert.equal(body.password, "admin-r6-password"); + return { ok: true, status: 200, body: { token: sessionToken, user: { id: "usr_r6_admin", username: "admin", role: "admin", status: "active" } } }; + }, + async introspect(token) { + calls.push({ op: "introspect", tokenPrefix: token.slice(0, 8) }); + assert.equal(token, sessionToken); + return { ok: true, status: 200, body: { active: true, principal: { userId: "usr_r6_admin", username: "admin", email: "admin@hwlab.local", role: "admin", scopes: ["session"], authType: "session" } } }; + }, + async redeem(token, body = {}) { + calls.push({ op: "redeem", tokenPrefix: token.slice(0, 8), body }); + assert.equal(token, sessionToken); + assert.equal(body.code, "hwl_redeem_test"); + return { ok: true, status: 200, body: { contractVersion: "user-billing-redeem-v1", result: { redeemed: true, creditAmount: 7, balanceBefore: 10, balanceAfter: 17, source: "redeem", ledgerId: "led_r6" } } }; + }, + async subscriptionSummary(token) { + calls.push({ op: "subscriptionSummary", tokenPrefix: token.slice(0, 8) }); + assert.equal(token, sessionToken); + return { ok: true, status: 200, body: { contractVersion: "user-billing-subscription-v1", planId: "default", payment: { configured: false, status: "unconfigured" } } }; + }, + async paymentSummary(token) { + calls.push({ op: "paymentSummary", tokenPrefix: token.slice(0, 8) }); + assert.equal(token, sessionToken); + return { ok: true, status: 200, body: { contractVersion: "user-billing-payment-v1", provider: { configured: false, status: "unconfigured" }, orders: [] } }; + }, + async adminRedeemCodes(options = {}) { + calls.push({ op: "adminRedeemCodes", options }); + return { ok: true, status: 200, body: { contractVersion: "user-billing-admin-redeem-codes-v1", codes: [{ id: "rdc_r6", codePrefix: "hwl_redeem", status: "active", creditAmount: 7 }], count: 1 } }; + }, + async createAdminRedeemCode(body = {}) { + calls.push({ op: "createAdminRedeemCode", body }); + assert.equal(body.creditAmount, 7); + assert.equal(body.createdByUsername, "admin"); + return { ok: true, status: 201, body: { contractVersion: "user-billing-admin-redeem-codes-v1", code: { id: "rdc_r6" }, redeemCode: "hwl_redeem_created", created: true } }; + }, + async updateAdminRedeemCode(codeId, body = {}) { + calls.push({ op: "updateAdminRedeemCode", codeId, body }); + assert.equal(codeId, "rdc_r6"); + assert.equal(body.status, "disabled"); + return { ok: true, status: 200, body: { contractVersion: "user-billing-admin-redeem-codes-v1", code: { id: codeId, status: body.status }, updated: true } }; + }, + async adminRedeemRedemptions(options = {}) { + calls.push({ op: "adminRedeemRedemptions", options }); + assert.equal(options.codeId, "rdc_r6"); + return { ok: true, status: 200, body: { contractVersion: "user-billing-admin-redeem-redemptions-v1", redemptions: [{ id: "rdm_r6", codeId: "rdc_r6", ledgerId: "led_r6", creditAmount: 7, status: "applied" }], count: 1 } }; + } + }; + const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, 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-r6-password" }) }); + assert.equal(login.status, 200); + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + + const redeem = await fetch(`http://127.0.0.1:${port}/v1/redeem`, { method: "POST", headers: { "content-type": "application/json", cookie }, body: JSON.stringify({ code: "hwl_redeem_test" }) }); + assert.equal(redeem.status, 200); + assert.equal((await redeem.json()).result.source, "redeem"); + + const subscription = await fetch(`http://127.0.0.1:${port}/v1/subscription/summary`, { headers: { cookie } }); + assert.equal(subscription.status, 200); + assert.equal((await subscription.json()).payment.status, "unconfigured"); + + const payment = await fetch(`http://127.0.0.1:${port}/v1/payments/summary`, { headers: { cookie } }); + assert.equal(payment.status, 200); + assert.equal((await payment.json()).provider.configured, false); + + const codes = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/redeem-codes?status=active`, { headers: { cookie } }); + assert.equal(codes.status, 200); + assert.equal((await codes.json()).codes[0].id, "rdc_r6"); + + const created = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/redeem-codes`, { method: "POST", headers: { "content-type": "application/json", cookie }, body: JSON.stringify({ creditAmount: 7, reason: "campaign" }) }); + assert.equal(created.status, 201); + assert.equal((await created.json()).created, true); + + const disabled = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/redeem-codes/rdc_r6`, { method: "PATCH", headers: { "content-type": "application/json", cookie }, body: JSON.stringify({ status: "disabled" }) }); + assert.equal(disabled.status, 200); + assert.equal((await disabled.json()).code.status, "disabled"); + + const redemptions = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/redeem-redemptions?codeId=rdc_r6`, { headers: { cookie } }); + assert.equal(redemptions.status, 200); + assert.equal((await redemptions.json()).redemptions[0].ledgerId, "led_r6"); + assert.equal(JSON.stringify(calls).includes(sessionToken), false); + for (const op of ["redeem", "subscriptionSummary", "paymentSummary", "adminRedeemCodes", "createAdminRedeemCode", "updateAdminRedeemCode", "adminRedeemRedemptions"]) { + assert.equal(calls.some((call) => call.op === op), true, op); + } + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/internal/userbilling/migrations/0004_redeem_subscription_payment.sql b/internal/userbilling/migrations/0004_redeem_subscription_payment.sql new file mode 100644 index 00000000..64c944a9 --- /dev/null +++ b/internal/userbilling/migrations/0004_redeem_subscription_payment.sql @@ -0,0 +1,80 @@ +CREATE TABLE IF NOT EXISTS hwlab_redeem_codes ( + id TEXT PRIMARY KEY, + code_hash TEXT NOT NULL UNIQUE, + code_prefix TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + credit_amount BIGINT NOT NULL, + reason TEXT NOT NULL DEFAULT 'redeem', + max_redemptions INTEGER NOT NULL DEFAULT 1, + per_user_limit INTEGER NOT NULL DEFAULT 1, + redeemed_count INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + created_by_user_id TEXT, + created_by_username TEXT, + disabled_at TIMESTAMPTZ, + 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', 'expired')), + CHECK (credit_amount > 0), + CHECK (max_redemptions > 0), + CHECK (per_user_limit > 0), + CHECK (redeemed_count >= 0) +); + +CREATE INDEX IF NOT EXISTS hwlab_redeem_codes_status_created_idx ON hwlab_redeem_codes(status, created_at DESC); +CREATE INDEX IF NOT EXISTS hwlab_redeem_codes_prefix_idx ON hwlab_redeem_codes(code_prefix); + +CREATE TABLE IF NOT EXISTS hwlab_redeem_redemptions ( + id TEXT PRIMARY KEY, + code_id TEXT NOT NULL REFERENCES hwlab_redeem_codes(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES hwlab_users(id) ON DELETE CASCADE, + ledger_id TEXT REFERENCES hwlab_credit_ledger(id) ON DELETE SET NULL, + credit_amount BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'applied', + idempotency_key TEXT UNIQUE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (credit_amount > 0), + CHECK (status IN ('applied', 'rejected')) +); + +CREATE INDEX IF NOT EXISTS hwlab_redeem_redemptions_code_created_idx ON hwlab_redeem_redemptions(code_id, created_at DESC); +CREATE INDEX IF NOT EXISTS hwlab_redeem_redemptions_user_created_idx ON hwlab_redeem_redemptions(user_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS hwlab_user_subscriptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES hwlab_users(id) ON DELETE CASCADE, + plan_id TEXT NOT NULL REFERENCES hwlab_billing_plans(id), + status TEXT NOT NULL DEFAULT 'active', + source TEXT NOT NULL DEFAULT 'manual', + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (status IN ('active', 'trialing', 'past_due', 'canceled', 'disabled')), + CHECK (source IN ('manual', 'redeem', 'payment_placeholder', 'system')) +); + +CREATE INDEX IF NOT EXISTS hwlab_user_subscriptions_user_status_idx ON hwlab_user_subscriptions(user_id, status, created_at DESC); + +CREATE TABLE IF NOT EXISTS hwlab_payment_orders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES hwlab_users(id) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT 'unconfigured', + status TEXT NOT NULL DEFAULT 'unconfigured', + credit_amount BIGINT NOT NULL DEFAULT 0, + amount_minor BIGINT NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'CNY', + reason 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 ('unconfigured', 'created', 'pending', 'paid', 'failed', 'cancelled', 'refunded')), + CHECK (credit_amount >= 0), + CHECK (amount_minor >= 0) +); + +CREATE INDEX IF NOT EXISTS hwlab_payment_orders_user_created_idx ON hwlab_payment_orders(user_id, created_at DESC); diff --git a/internal/userbilling/redeem.go b/internal/userbilling/redeem.go new file mode 100644 index 00000000..b7dd3854 --- /dev/null +++ b/internal/userbilling/redeem.go @@ -0,0 +1,572 @@ +package userbilling + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +type redeemCodeSummary struct { + ID string `json:"id"` + CodePrefix string `json:"codePrefix"` + DisplayName string `json:"displayName"` + Status string `json:"status"` + CreditAmount int64 `json:"creditAmount"` + Reason string `json:"reason"` + MaxRedemptions int64 `json:"maxRedemptions"` + PerUserLimit int64 `json:"perUserLimit"` + RedeemedCount int64 `json:"redeemedCount"` + RemainingCount int64 `json:"remainingCount"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + CreatedByUserID string `json:"createdByUserId,omitempty"` + CreatedByUsername string `json:"createdByUsername,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DisabledAt *time.Time `json:"disabledAt,omitempty"` +} + +type redeemRedemptionSummary struct { + ID string `json:"id"` + CodeID string `json:"codeId"` + CodePrefix string `json:"codePrefix"` + UserID string `json:"userId"` + LedgerID string `json:"ledgerId,omitempty"` + CreditAmount int64 `json:"creditAmount"` + Status string `json:"status"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type redeemApplyResult struct { + Redeemed bool `json:"redeemed"` + Idempotent bool `json:"idempotent"` + RedemptionID string `json:"redemptionId"` + CodeID string `json:"codeId"` + CodePrefix string `json:"codePrefix"` + UserID string `json:"userId"` + LedgerID string `json:"ledgerId"` + CreditAmount int64 `json:"creditAmount"` + BalanceBefore int64 `json:"balanceBefore"` + BalanceAfter int64 `json:"balanceAfter"` + Reason string `json:"reason"` + Source string `json:"source"` + Status string `json:"status"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` +} + +func (s *Server) handleRedeem(w http.ResponseWriter, r *http.Request) { + principal, ok := s.requirePrincipal(w, r) + if !ok { + return + } + var req struct { + Code string `json:"code"` + 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.applyRedeemCode(ctx, principal.UserID, strings.TrimSpace(req.Code), strings.TrimSpace(req.IdempotencyKey), req.Metadata) + if err != nil { + status, code, message := redeemError(err) + writeAPIError(w, status, code, message) + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-redeem-v1", "result": result, "valuesRedacted": true}) +} + +func (s *Server) handleSubscriptionSummary(w http.ResponseWriter, r *http.Request) { + principal, ok := s.requirePrincipal(w, r) + if !ok { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + summary, err := s.subscriptionSummary(ctx, principal.UserID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "subscription_summary_failed", "could not load subscription summary") + return + } + writeJSON(w, http.StatusOK, summary) +} + +func (s *Server) handlePaymentSummary(w http.ResponseWriter, r *http.Request) { + principal, ok := s.requirePrincipal(w, r) + if !ok { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + summary, err := s.paymentSummary(ctx, principal.UserID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "payment_summary_failed", "could not load payment summary") + return + } + writeJSON(w, http.StatusOK, summary) +} + +func (s *Server) handleAdminRedeemCodes(w http.ResponseWriter, r *http.Request) { + if !s.internalAllowed(w, r) || !s.databaseAvailable(w) { + return + } + switch r.Method { + case http.MethodGet: + s.handleAdminRedeemCodeList(w, r) + case http.MethodPost: + s.handleAdminCreateRedeemCode(w, r) + default: + w.Header().Set("Allow", "GET, POST") + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleAdminRedeemCodeByID(w http.ResponseWriter, r *http.Request) { + if !s.internalAllowed(w, r) || !s.databaseAvailable(w) { + return + } + codeID := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/internal/admin/redeem-codes/")) + if codeID == "" || strings.Contains(codeID, "/") { + writeAPIError(w, http.StatusNotFound, "redeem_code_not_found", "redeem code not found") + return + } + if r.Method != http.MethodPatch { + w.Header().Set("Allow", "PATCH") + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + var req struct { + Status string `json:"status"` + } + if !decodeJSON(w, r, &req) { + return + } + status := strings.TrimSpace(strings.ToLower(req.Status)) + if status != "active" && status != "disabled" { + writeAPIError(w, http.StatusBadRequest, "invalid_status", "status must be active or disabled") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + code, err := s.updateRedeemCodeStatus(ctx, codeID, status) + if errors.Is(err, sql.ErrNoRows) { + writeAPIError(w, http.StatusNotFound, "redeem_code_not_found", "redeem code not found") + return + } + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "redeem_code_update_failed", "could not update redeem code") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-redeem-codes-v1", "code": code, "updated": true, "valuesRedacted": true}) +} + +func (s *Server) handleAdminRedeemRedemptions(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() + limit := redeemPositiveInt(r.URL.Query().Get("limit"), 50) + if limit > 200 { + limit = 200 + } + rows, err := s.redeemRedemptions(ctx, limit, strings.TrimSpace(r.URL.Query().Get("userId")), strings.TrimSpace(r.URL.Query().Get("codeId"))) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "redeem_redemptions_failed", "could not load redeem redemptions") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-redeem-redemptions-v1", "redemptions": rows, "count": len(rows), "valuesRedacted": true}) +} + +func (s *Server) handleAdminCreateRedeemCode(w http.ResponseWriter, r *http.Request) { + var req struct { + DisplayName string `json:"displayName"` + CreditAmount int64 `json:"creditAmount"` + Reason string `json:"reason"` + MaxRedemptions int64 `json:"maxRedemptions"` + PerUserLimit int64 `json:"perUserLimit"` + ExpiresAt string `json:"expiresAt"` + CreatedByUserID string `json:"createdByUserId"` + CreatedByUsername string `json:"createdByUsername"` + Metadata map[string]any `json:"metadata"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.CreditAmount <= 0 { + writeAPIError(w, http.StatusBadRequest, "invalid_credit_amount", "creditAmount must be positive") + return + } + if req.MaxRedemptions <= 0 { + req.MaxRedemptions = 1 + } + if req.PerUserLimit <= 0 { + req.PerUserLimit = 1 + } + var expiresAt *time.Time + if text := strings.TrimSpace(req.ExpiresAt); text != "" { + parsed, err := time.Parse(time.RFC3339, text) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid_expires_at", "expiresAt must be RFC3339") + return + } + expiresAt = &parsed + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + code, rawCode, err := s.createRedeemCode(ctx, req.DisplayName, req.CreditAmount, req.Reason, req.MaxRedemptions, req.PerUserLimit, expiresAt, req.CreatedByUserID, req.CreatedByUsername, req.Metadata) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "redeem_code_create_failed", "could not create redeem code") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"contractVersion": "user-billing-admin-redeem-codes-v1", "code": code, "redeemCode": rawCode, "created": true, "valuesRedacted": true}) +} + +func (s *Server) handleAdminRedeemCodeList(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + limit := redeemPositiveInt(r.URL.Query().Get("limit"), 50) + if limit > 200 { + limit = 200 + } + codes, err := s.redeemCodes(ctx, limit, strings.TrimSpace(r.URL.Query().Get("status"))) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "redeem_codes_failed", "could not load redeem codes") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-redeem-codes-v1", "codes": codes, "count": len(codes), "valuesRedacted": true}) +} + +func (s *Server) createRedeemCode(ctx context.Context, displayName string, creditAmount int64, reason string, maxRedemptions int64, perUserLimit int64, expiresAt *time.Time, createdByUserID string, createdByUsername string, metadata map[string]any) (redeemCodeSummary, string, error) { + rawCode := "hwl_redeem_" + randomToken(18) + codeHash := redeemCodeHash(rawCode) + codePrefix := redeemCodePrefix(rawCode) + if strings.TrimSpace(reason) == "" { + reason = "redeem" + } + codeID := newID("rdc") + _, err := s.db.ExecContext(ctx, `INSERT INTO hwlab_redeem_codes (id, code_hash, code_prefix, display_name, credit_amount, reason, max_redemptions, per_user_limit, expires_at, created_by_user_id, created_by_username, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, nullif($10, ''), nullif($11, ''), $12::jsonb)`, codeID, codeHash, codePrefix, strings.TrimSpace(displayName), creditAmount, strings.TrimSpace(reason), maxRedemptions, perUserLimit, expiresAt, strings.TrimSpace(createdByUserID), strings.TrimSpace(createdByUsername), jsonObject(metadata)) + if err != nil { + return redeemCodeSummary{}, "", err + } + code, err := s.redeemCodeByID(ctx, codeID) + return code, rawCode, err +} + +func (s *Server) applyRedeemCode(ctx context.Context, userID, rawCode, idempotencyKey string, metadata map[string]any) (redeemApplyResult, error) { + if rawCode == "" { + return redeemApplyResult{}, errRedeemCodeRequired + } + if idempotencyKey != "" { + result, found, err := s.redeemResultByIdempotency(ctx, idempotencyKey) + if err != nil || found { + return result, err + } + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return redeemApplyResult{}, err + } + defer tx.Rollback() + var code redeemCodeSummary + var expiresAt sql.NullTime + var disabledAt sql.NullTime + var metadataRaw string + err = tx.QueryRowContext(ctx, `SELECT id, code_prefix, display_name, status, credit_amount, reason, max_redemptions, per_user_limit, redeemed_count, expires_at, COALESCE(created_by_user_id, ''), COALESCE(created_by_username, ''), metadata::text, created_at, updated_at, disabled_at FROM hwlab_redeem_codes WHERE code_hash = $1 FOR UPDATE`, redeemCodeHash(rawCode)).Scan(&code.ID, &code.CodePrefix, &code.DisplayName, &code.Status, &code.CreditAmount, &code.Reason, &code.MaxRedemptions, &code.PerUserLimit, &code.RedeemedCount, &expiresAt, &code.CreatedByUserID, &code.CreatedByUsername, &metadataRaw, &code.CreatedAt, &code.UpdatedAt, &disabledAt) + if errors.Is(err, sql.ErrNoRows) { + return redeemApplyResult{}, errRedeemCodeNotFound + } + if err != nil { + return redeemApplyResult{}, err + } + if expiresAt.Valid { + code.ExpiresAt = &expiresAt.Time + } + if disabledAt.Valid { + code.DisabledAt = &disabledAt.Time + } + code.Metadata = parseJSONMap(metadataRaw) + if code.Status != "active" { + return redeemApplyResult{}, errRedeemCodeDisabled + } + if code.ExpiresAt != nil && time.Now().After(*code.ExpiresAt) { + _, _ = tx.ExecContext(ctx, `UPDATE hwlab_redeem_codes SET status = 'expired', updated_at = now() WHERE id = $1`, code.ID) + return redeemApplyResult{}, errRedeemCodeExpired + } + if code.RedeemedCount >= code.MaxRedemptions { + return redeemApplyResult{}, errRedeemCodeExhausted + } + var userRedemptions int64 + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM hwlab_redeem_redemptions WHERE code_id = $1 AND user_id = $2 AND status = 'applied'`, code.ID, userID).Scan(&userRedemptions); err != nil { + return redeemApplyResult{}, err + } + if userRedemptions >= code.PerUserLimit { + return redeemApplyResult{}, errRedeemAlreadyUsed + } + 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 redeemApplyResult{}, err + } + newBalance := balance + code.CreditAmount + ledgerID := newID("led") + redemptionID := newID("rdm") + mergedMetadata := map[string]any{"redeemCodeId": code.ID, "redemptionId": redemptionID, "codePrefix": code.CodePrefix, "valuesRedacted": true} + for key, value := range metadata { + mergedMetadata[key] = value + } + _, 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 redeemApplyResult{}, err + } + _, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, idempotency_key, metadata) VALUES ($1, $2, $3, $4, $5, 'redeem', $6, 'redeem', 'applied', nullif($7, ''), $8::jsonb)`, ledgerID, userID, code.CreditAmount, balance, newBalance, code.Reason, idempotencyKey, jsonObject(mergedMetadata)) + if err != nil { + return redeemApplyResult{}, err + } + _, err = tx.ExecContext(ctx, `INSERT INTO hwlab_redeem_redemptions (id, code_id, user_id, ledger_id, credit_amount, status, idempotency_key, metadata) VALUES ($1, $2, $3, $4, $5, 'applied', nullif($6, ''), $7::jsonb)`, redemptionID, code.ID, userID, ledgerID, code.CreditAmount, idempotencyKey, jsonObject(mergedMetadata)) + if err != nil { + return redeemApplyResult{}, err + } + _, err = tx.ExecContext(ctx, `UPDATE hwlab_redeem_codes SET redeemed_count = redeemed_count + 1, updated_at = now() WHERE id = $1`, code.ID) + if err != nil { + return redeemApplyResult{}, err + } + result := redeemApplyResult{Redeemed: true, CodeID: code.ID, CodePrefix: code.CodePrefix, UserID: userID, RedemptionID: redemptionID, LedgerID: ledgerID, CreditAmount: code.CreditAmount, BalanceBefore: balance, BalanceAfter: newBalance, Reason: code.Reason, Source: "redeem", Status: "applied", IdempotencyKey: idempotencyKey} + return result, tx.Commit() +} + +func (s *Server) redeemResultByIdempotency(ctx context.Context, idempotencyKey string) (redeemApplyResult, bool, error) { + var result redeemApplyResult + var before sql.NullInt64 + err := s.db.QueryRowContext(ctx, `SELECT r.id, r.code_id, c.code_prefix, r.user_id, COALESCE(r.ledger_id, ''), r.credit_amount, r.status, COALESCE(r.idempotency_key, ''), COALESCE(l.balance_before, l.balance_after - l.delta_credits), COALESCE(l.balance_after, 0), COALESCE(l.reason, c.reason) FROM hwlab_redeem_redemptions r JOIN hwlab_redeem_codes c ON c.id = r.code_id LEFT JOIN hwlab_credit_ledger l ON l.id = r.ledger_id WHERE r.idempotency_key = $1`, idempotencyKey).Scan(&result.RedemptionID, &result.CodeID, &result.CodePrefix, &result.UserID, &result.LedgerID, &result.CreditAmount, &result.Status, &result.IdempotencyKey, &before, &result.BalanceAfter, &result.Reason) + if errors.Is(err, sql.ErrNoRows) { + return redeemApplyResult{}, false, nil + } + if err != nil { + return redeemApplyResult{}, false, err + } + if before.Valid { + result.BalanceBefore = before.Int64 + } + result.Redeemed = true + result.Idempotent = true + result.Source = "redeem" + return result, true, nil +} + +func (s *Server) redeemCodes(ctx context.Context, limit int, status string) ([]redeemCodeSummary, error) { + args := []any{} + where := "" + if status != "" { + args = append(args, status) + where = "WHERE status = $1" + } + args = append(args, limit) + query := `SELECT id, code_prefix, display_name, status, credit_amount, reason, max_redemptions, per_user_limit, redeemed_count, expires_at, COALESCE(created_by_user_id, ''), COALESCE(created_by_username, ''), metadata::text, created_at, updated_at, disabled_at FROM hwlab_redeem_codes ` + where + ` ORDER BY created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(args)) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + items := []redeemCodeSummary{} + for rows.Next() { + item, err := scanRedeemCode(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Server) redeemCodeByID(ctx context.Context, codeID string) (redeemCodeSummary, error) { + row := s.db.QueryRowContext(ctx, `SELECT id, code_prefix, display_name, status, credit_amount, reason, max_redemptions, per_user_limit, redeemed_count, expires_at, COALESCE(created_by_user_id, ''), COALESCE(created_by_username, ''), metadata::text, created_at, updated_at, disabled_at FROM hwlab_redeem_codes WHERE id = $1`, codeID) + return scanRedeemCode(row) +} + +func (s *Server) updateRedeemCodeStatus(ctx context.Context, codeID, status string) (redeemCodeSummary, error) { + _, err := s.db.ExecContext(ctx, `UPDATE hwlab_redeem_codes SET status = $2, disabled_at = CASE WHEN $2 = 'disabled' THEN now() ELSE NULL END, updated_at = now() WHERE id = $1`, codeID, status) + if err != nil { + return redeemCodeSummary{}, err + } + return s.redeemCodeByID(ctx, codeID) +} + +func (s *Server) redeemRedemptions(ctx context.Context, limit int, userID, codeID string) ([]redeemRedemptionSummary, error) { + args := []any{} + filters := []string{} + if userID != "" { + args = append(args, userID) + filters = append(filters, "r.user_id = $"+strconv.Itoa(len(args))) + } + if codeID != "" { + args = append(args, codeID) + filters = append(filters, "r.code_id = $"+strconv.Itoa(len(args))) + } + where := "" + if len(filters) > 0 { + where = "WHERE " + strings.Join(filters, " AND ") + } + args = append(args, limit) + query := `SELECT r.id, r.code_id, c.code_prefix, r.user_id, COALESCE(r.ledger_id, ''), r.credit_amount, r.status, COALESCE(r.idempotency_key, ''), r.metadata::text, r.created_at FROM hwlab_redeem_redemptions r JOIN hwlab_redeem_codes c ON c.id = r.code_id ` + where + ` ORDER BY r.created_at DESC, r.id DESC LIMIT $` + strconv.Itoa(len(args)) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + items := []redeemRedemptionSummary{} + for rows.Next() { + var item redeemRedemptionSummary + var metadataRaw string + if err := rows.Scan(&item.ID, &item.CodeID, &item.CodePrefix, &item.UserID, &item.LedgerID, &item.CreditAmount, &item.Status, &item.IdempotencyKey, &metadataRaw, &item.CreatedAt); err != nil { + return nil, err + } + item.Metadata = parseJSONMap(metadataRaw) + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Server) subscriptionSummary(ctx context.Context, userID string) (map[string]any, error) { + planID, plan, entitlements, err := s.accountPlanContext(ctx, userID) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, `SELECT id, plan_id, status, source, current_period_start, current_period_end, metadata::text, created_at, updated_at FROM hwlab_user_subscriptions WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + subscriptions := []map[string]any{} + for rows.Next() { + var id, rowPlanID, status, source, metadataRaw string + var start, end sql.NullTime + var createdAt, updatedAt time.Time + if err := rows.Scan(&id, &rowPlanID, &status, &source, &start, &end, &metadataRaw, &createdAt, &updatedAt); err != nil { + return nil, err + } + item := map[string]any{"id": id, "planId": rowPlanID, "status": status, "source": source, "metadata": parseJSONMap(metadataRaw), "createdAt": createdAt, "updatedAt": updatedAt} + if start.Valid { + item["currentPeriodStart"] = start.Time + } + if end.Valid { + item["currentPeriodEnd"] = end.Time + } + subscriptions = append(subscriptions, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return map[string]any{"contractVersion": "user-billing-subscription-v1", "planId": planID, "plan": plan, "entitlements": entitlements, "subscriptions": subscriptions, "payment": paymentProviderStatus(), "valuesRedacted": true}, nil +} + +func (s *Server) paymentSummary(ctx context.Context, userID string) (map[string]any, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, provider, status, credit_amount, amount_minor, currency, reason, metadata::text, created_at, updated_at FROM hwlab_payment_orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + orders := []map[string]any{} + for rows.Next() { + var id, provider, status, currency, reason, metadataRaw string + var credits, amountMinor int64 + var createdAt, updatedAt time.Time + if err := rows.Scan(&id, &provider, &status, &credits, &amountMinor, ¤cy, &reason, &metadataRaw, &createdAt, &updatedAt); err != nil { + return nil, err + } + orders = append(orders, map[string]any{"id": id, "provider": provider, "status": status, "creditAmount": credits, "amountMinor": amountMinor, "currency": currency, "reason": reason, "metadata": parseJSONMap(metadataRaw), "createdAt": createdAt, "updatedAt": updatedAt}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return map[string]any{"contractVersion": "user-billing-payment-v1", "provider": paymentProviderStatus(), "orders": orders, "count": len(orders), "valuesRedacted": true}, nil +} + +type redeemCodeScanner interface { + Scan(dest ...any) error +} + +func scanRedeemCode(row redeemCodeScanner) (redeemCodeSummary, error) { + var item redeemCodeSummary + var expiresAt sql.NullTime + var disabledAt sql.NullTime + var metadataRaw string + if err := row.Scan(&item.ID, &item.CodePrefix, &item.DisplayName, &item.Status, &item.CreditAmount, &item.Reason, &item.MaxRedemptions, &item.PerUserLimit, &item.RedeemedCount, &expiresAt, &item.CreatedByUserID, &item.CreatedByUsername, &metadataRaw, &item.CreatedAt, &item.UpdatedAt, &disabledAt); err != nil { + return item, err + } + if expiresAt.Valid { + item.ExpiresAt = &expiresAt.Time + } + if disabledAt.Valid { + item.DisabledAt = &disabledAt.Time + } + item.Metadata = parseJSONMap(metadataRaw) + item.RemainingCount = item.MaxRedemptions - item.RedeemedCount + if item.RemainingCount < 0 { + item.RemainingCount = 0 + } + return item, nil +} + +func paymentProviderStatus() map[string]any { + return map[string]any{"configured": false, "status": "unconfigured", "provider": "none", "message": "payment provider is not configured; redeem/manual recharge is available", "valuesRedacted": true} +} + +func redeemCodeHash(code string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(code))) + return hex.EncodeToString(sum[:]) +} + +func redeemCodePrefix(code string) string { + text := strings.TrimSpace(code) + if len(text) <= 16 { + return text + } + return text[:16] +} + +func redeemPositiveInt(value string, fallback int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +var ( + errRedeemCodeRequired = errors.New("redeem code is required") + errRedeemCodeNotFound = errors.New("redeem code not found") + errRedeemCodeDisabled = errors.New("redeem code is disabled") + errRedeemCodeExpired = errors.New("redeem code is expired") + errRedeemCodeExhausted = errors.New("redeem code is exhausted") + errRedeemAlreadyUsed = errors.New("redeem code already used by this user") +) + +func redeemError(err error) (int, string, string) { + switch { + case errors.Is(err, errRedeemCodeRequired): + return http.StatusBadRequest, "redeem_code_required", "redeem code is required" + case errors.Is(err, errRedeemCodeNotFound): + return http.StatusNotFound, "redeem_code_not_found", "redeem code was not found" + case errors.Is(err, errRedeemCodeDisabled): + return http.StatusForbidden, "redeem_code_disabled", "redeem code is disabled" + case errors.Is(err, errRedeemCodeExpired): + return http.StatusForbidden, "redeem_code_expired", "redeem code is expired" + case errors.Is(err, errRedeemCodeExhausted): + return http.StatusConflict, "redeem_code_exhausted", "redeem code has no remaining redemptions" + case errors.Is(err, errRedeemAlreadyUsed): + return http.StatusConflict, "redeem_code_already_used", "redeem code has already been redeemed by this user" + default: + return http.StatusInternalServerError, "redeem_failed", fmt.Sprintf("redeem failed: %s", err.Error()) + } +} diff --git a/internal/userbilling/service.go b/internal/userbilling/service.go index eded3853..a8c70c27 100644 --- a/internal/userbilling/service.go +++ b/internal/userbilling/service.go @@ -377,6 +377,9 @@ func (s *Server) routes() { s.route(http.MethodPatch, "/v1/me/profile", s.handleUpdateProfile) s.route(http.MethodPost, "/v1/me/password", s.handleChangePassword) s.route(http.MethodGet, "/v1/billing/summary", s.handleBillingSummary) + s.route(http.MethodPost, "/v1/redeem", s.handleRedeem) + s.route(http.MethodGet, "/v1/subscription/summary", s.handleSubscriptionSummary) + s.route(http.MethodGet, "/v1/payments/summary", s.handlePaymentSummary) s.mux.HandleFunc("/v1/api-keys", s.handleAPIKeys) s.mux.HandleFunc("/v1/api-keys/", s.handleAPIKeyByID) s.route(http.MethodPost, "/internal/auth/introspect", s.handleIntrospect) @@ -389,6 +392,9 @@ func (s *Server) routes() { s.route(http.MethodGet, "/internal/admin/usage/export", s.handleAdminUsageExport) s.route(http.MethodGet, "/internal/admin/ledger", s.handleAdminLedger) s.route(http.MethodGet, "/internal/admin/ledger/export", s.handleAdminLedgerExport) + s.mux.HandleFunc("/internal/admin/redeem-codes", s.handleAdminRedeemCodes) + s.mux.HandleFunc("/internal/admin/redeem-codes/", s.handleAdminRedeemCodeByID) + s.route(http.MethodGet, "/internal/admin/redeem-redemptions", s.handleAdminRedeemRedemptions) 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) diff --git a/web/hwlab-cloud-web/src/api/billing.ts b/web/hwlab-cloud-web/src/api/billing.ts new file mode 100644 index 00000000..cf5fcae3 --- /dev/null +++ b/web/hwlab-cloud-web/src/api/billing.ts @@ -0,0 +1,22 @@ +import { fetchJson } from "./client"; +import type { AdminRedeemCodesResponse, AdminRedeemRedemptionsResponse, ApiResult, PaymentSummaryResponse, RedeemResponse, SubscriptionSummaryResponse } from "@/types"; + +export const billingAPI = { + redeem: (body: { code: string; idempotencyKey?: string }): Promise> => fetchJson("/v1/redeem", { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "redeem" }), + subscriptionSummary: (): Promise> => fetchJson("/v1/subscription/summary", { timeoutMs: 12000, timeoutName: "subscription summary" }), + paymentSummary: (): Promise> => fetchJson("/v1/payments/summary", { timeoutMs: 12000, timeoutName: "payment summary" }), + adminRedeemCodes: (params: { limit?: number; status?: string } = {}): Promise> => fetchJson(redeemPath("/v1/admin/billing/redeem-codes", params), { timeoutMs: 12000, timeoutName: "admin redeem codes" }), + createAdminRedeemCode: (body: Record): Promise> => fetchJson("/v1/admin/billing/redeem-codes", { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "create redeem code" }), + updateAdminRedeemCode: (codeId: string, body: Record): Promise> => fetchJson(`/v1/admin/billing/redeem-codes/${encodeURIComponent(codeId)}`, { method: "PATCH", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "update redeem code" }), + adminRedeemRedemptions: (params: { limit?: number; userId?: string; codeId?: string } = {}): Promise> => fetchJson(redeemPath("/v1/admin/billing/redeem-redemptions", params), { timeoutMs: 12000, timeoutName: "admin redeem redemptions" }) +}; + +function redeemPath(path: string, params: Record = {}): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + const text = String(value ?? "").trim(); + if (text) query.set(key, text); + } + const suffix = query.toString(); + return suffix ? `${path}?${suffix}` : path; +} diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index 1a9b334d..29819df0 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -7,6 +7,7 @@ export { accessAPI } from "./access"; export { providerProfilesAPI } from "./providerProfiles"; export { apiKeysAPI } from "./apiKeys"; export { usageAPI } from "./usage"; +export { billingAPI } from "./billing"; export { systemAPI, type SkillUploadFileInput } from "./system"; import { fetchJson } from "./client"; @@ -17,6 +18,7 @@ import { authAPI } from "./auth"; import { hwpodAPI } from "./hwpod"; import { providerProfilesAPI } from "./providerProfiles"; import { usageAPI } from "./usage"; +import { billingAPI } from "./billing"; import { workbenchAPI } from "./workbench"; import { systemAPI } from "./system"; import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types"; @@ -30,6 +32,7 @@ export const api = { providerProfiles: providerProfilesAPI, apiKeys: apiKeysAPI, usage: usageAPI, + billing: billingAPI, system: systemAPI, healthLive: (): Promise> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }), health: (): Promise> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }), diff --git a/web/hwlab-cloud-web/src/components/layout/AppShell.vue b/web/hwlab-cloud-web/src/components/layout/AppShell.vue index a2428c99..6f373937 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppShell.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppShell.vue @@ -11,8 +11,8 @@ const app = useAppStore(); const navSections = [ { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench" }, { name: "Dashboard", label: "概览", path: "/dashboard" }] }, - { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys" }, { name: "Usage", label: "用量", path: "/usage" }] }, - { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access" }, { name: "Users", label: "用户", path: "/admin/users" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles" }] }, + { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys" }, { name: "Usage", label: "用量", path: "/usage" }, { name: "Billing", label: "充值", path: "/billing" }] }, + { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access" }, { name: "Users", label: "用户", path: "/admin/users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles" }] }, { title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate" }, { name: "Performance", label: "性能", path: "/performance" }, { name: "Skills", label: "Skills", path: "/skills" }, { name: "Settings", label: "设置", path: "/settings" }, { name: "Help", label: "帮助", path: "/help" }] } ]; diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index 82b621cd..b403a2e9 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -9,9 +9,11 @@ const routes: RouteRecordRaw[] = [ { path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: () => import("@/views/workbench/CodeWorkbenchView.vue"), meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } }, { path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, title: "API Keys", section: "user" } }, { path: "/usage", name: "Usage", component: () => import("@/views/user/UsageView.vue"), meta: { requiresAuth: true, title: "用量与性能", section: "user" } }, + { path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, title: "充值与订阅", section: "user" } }, { path: "/performance", name: "Performance", component: () => import("@/views/PerformanceView.vue"), meta: { requiresAuth: true, title: "性能监控", section: "system" } }, { path: "/admin/access", alias: ["/access"], name: "Access", component: () => import("@/views/admin/AccessView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "授权管理", section: "admin" } }, { path: "/admin/users", name: "Users", component: () => import("@/views/admin/UsersView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "用户管理", section: "admin" } }, + { path: "/admin/billing", name: "AdminBilling", component: () => import("@/views/admin/BillingView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "兑换与订阅", section: "admin" } }, { path: "/admin/hwpod-groups", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "HWPOD 分组", section: "admin" } }, { path: "/admin/provider-profiles", name: "ProviderProfiles", component: () => import("@/views/admin/ProviderProfilesView.vue"), meta: { requiresAuth: true, requiresAdmin: true, title: "Provider Profiles", section: "admin" } }, { path: "/skills", name: "Skills", component: () => import("@/views/SkillsView.vue"), meta: { requiresAuth: true, title: "技能包", section: "system" } }, diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index c9671a52..4f6a0909 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -438,6 +438,108 @@ export interface AdminAuditResponse { [key: string]: unknown; } +export interface RedeemResult { + redeemed?: boolean; + idempotent?: boolean; + redemptionId?: string; + codeId?: string; + codePrefix?: string; + userId?: string; + ledgerId?: string; + creditAmount?: number; + balanceBefore?: number; + balanceAfter?: number; + reason?: string; + source?: string; + status?: string; + idempotencyKey?: string; +} + +export interface RedeemResponse { + contractVersion?: string; + result?: RedeemResult; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + +export interface RedeemCodeSummary { + id?: string; + codePrefix?: string; + displayName?: string; + status?: string; + creditAmount?: number; + reason?: string; + maxRedemptions?: number; + perUserLimit?: number; + redeemedCount?: number; + remainingCount?: number; + expiresAt?: string | null; + createdByUserId?: string; + createdByUsername?: string; + metadata?: Record; + createdAt?: string; + updatedAt?: string; + disabledAt?: string | null; +} + +export interface RedeemRedemptionSummary { + id?: string; + codeId?: string; + codePrefix?: string; + userId?: string; + ledgerId?: string; + creditAmount?: number; + status?: string; + idempotencyKey?: string; + metadata?: Record; + createdAt?: string; +} + +export interface AdminRedeemCodesResponse { + contractVersion?: string; + codes?: RedeemCodeSummary[]; + code?: RedeemCodeSummary; + redeemCode?: string; + created?: boolean; + updated?: boolean; + count?: number; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + +export interface AdminRedeemRedemptionsResponse { + contractVersion?: string; + redemptions?: RedeemRedemptionSummary[]; + count?: number; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + +export interface SubscriptionSummaryResponse { + contractVersion?: string; + planId?: string; + plan?: BillingPlanSummary; + entitlements?: ResourceEntitlementSummary[]; + subscriptions?: Array>; + payment?: Record; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + +export interface PaymentSummaryResponse { + contractVersion?: string; + provider?: Record; + orders?: Array>; + count?: number; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + export interface AdminBillingMutationResponse { ok?: boolean; userId?: string; diff --git a/web/hwlab-cloud-web/src/views/admin/BillingView.vue b/web/hwlab-cloud-web/src/views/admin/BillingView.vue new file mode 100644 index 00000000..b7e16836 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/admin/BillingView.vue @@ -0,0 +1,152 @@ + + + diff --git a/web/hwlab-cloud-web/src/views/user/BillingView.vue b/web/hwlab-cloud-web/src/views/user/BillingView.vue new file mode 100644 index 00000000..12d7ca76 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/user/BillingView.vue @@ -0,0 +1,107 @@ + + +