feat: add redeem billing loop
This commit is contained in:
@@ -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 ?? ""),
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ApiResult<RedeemResponse>> => fetchJson("/v1/redeem", { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "redeem" }),
|
||||
subscriptionSummary: (): Promise<ApiResult<SubscriptionSummaryResponse>> => fetchJson("/v1/subscription/summary", { timeoutMs: 12000, timeoutName: "subscription summary" }),
|
||||
paymentSummary: (): Promise<ApiResult<PaymentSummaryResponse>> => fetchJson("/v1/payments/summary", { timeoutMs: 12000, timeoutName: "payment summary" }),
|
||||
adminRedeemCodes: (params: { limit?: number; status?: string } = {}): Promise<ApiResult<AdminRedeemCodesResponse>> => fetchJson(redeemPath("/v1/admin/billing/redeem-codes", params), { timeoutMs: 12000, timeoutName: "admin redeem codes" }),
|
||||
createAdminRedeemCode: (body: Record<string, unknown>): Promise<ApiResult<AdminRedeemCodesResponse>> => fetchJson("/v1/admin/billing/redeem-codes", { method: "POST", body: JSON.stringify(body), timeoutMs: 12000, timeoutName: "create redeem code" }),
|
||||
updateAdminRedeemCode: (codeId: string, body: Record<string, unknown>): Promise<ApiResult<AdminRedeemCodesResponse>> => 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<ApiResult<AdminRedeemRedemptionsResponse>> => fetchJson(redeemPath("/v1/admin/billing/redeem-redemptions", params), { timeoutMs: 12000, timeoutName: "admin redeem redemptions" })
|
||||
};
|
||||
|
||||
function redeemPath(path: string, params: Record<string, unknown> = {}): 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;
|
||||
}
|
||||
@@ -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<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
|
||||
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
|
||||
|
||||
@@ -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" }] }
|
||||
];
|
||||
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -438,6 +438,108 @@ export interface AdminAuditResponse<T> {
|
||||
[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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface AdminRedeemCodesResponse {
|
||||
contractVersion?: string;
|
||||
codes?: RedeemCodeSummary[];
|
||||
code?: RedeemCodeSummary;
|
||||
redeemCode?: string;
|
||||
created?: boolean;
|
||||
updated?: boolean;
|
||||
count?: number;
|
||||
proxy?: Record<string, unknown>;
|
||||
valuesRedacted?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdminRedeemRedemptionsResponse {
|
||||
contractVersion?: string;
|
||||
redemptions?: RedeemRedemptionSummary[];
|
||||
count?: number;
|
||||
proxy?: Record<string, unknown>;
|
||||
valuesRedacted?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SubscriptionSummaryResponse {
|
||||
contractVersion?: string;
|
||||
planId?: string;
|
||||
plan?: BillingPlanSummary;
|
||||
entitlements?: ResourceEntitlementSummary[];
|
||||
subscriptions?: Array<Record<string, unknown>>;
|
||||
payment?: Record<string, unknown>;
|
||||
proxy?: Record<string, unknown>;
|
||||
valuesRedacted?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PaymentSummaryResponse {
|
||||
contractVersion?: string;
|
||||
provider?: Record<string, unknown>;
|
||||
orders?: Array<Record<string, unknown>>;
|
||||
count?: number;
|
||||
proxy?: Record<string, unknown>;
|
||||
valuesRedacted?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdminBillingMutationResponse {
|
||||
ok?: boolean;
|
||||
userId?: string;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { billingAPI } from "@/api";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import type { RedeemCodeSummary, RedeemRedemptionSummary } from "@/types";
|
||||
|
||||
const codes = ref<RedeemCodeSummary[]>([]);
|
||||
const redemptions = ref<RedeemRedemptionSummary[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const pending = ref<string | null>(null);
|
||||
const createdCode = ref<string | null>(null);
|
||||
const filters = reactive({ status: "", userId: "", codeId: "" });
|
||||
const form = reactive({ displayName: "", creditAmount: 10, reason: "redeem", maxRedemptions: 1, perUserLimit: 1, expiresAt: "" });
|
||||
|
||||
onMounted(refreshAll);
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
loading.value = true;
|
||||
await Promise.all([loadCodes(), loadRedemptions()]);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function loadCodes(): Promise<void> {
|
||||
const response = await billingAPI.adminRedeemCodes({ limit: 100, status: filters.status });
|
||||
if (!response.ok) {
|
||||
error.value = response.error ?? `HTTP ${response.status}`;
|
||||
codes.value = [];
|
||||
return;
|
||||
}
|
||||
codes.value = response.data?.codes ?? [];
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
async function loadRedemptions(): Promise<void> {
|
||||
const response = await billingAPI.adminRedeemRedemptions({ limit: 100, userId: filters.userId, codeId: filters.codeId });
|
||||
if (response.ok) redemptions.value = response.data?.redemptions ?? [];
|
||||
}
|
||||
|
||||
async function createCode(): Promise<void> {
|
||||
pending.value = "create";
|
||||
createdCode.value = null;
|
||||
error.value = null;
|
||||
const body: Record<string, unknown> = { ...form, creditAmount: Number(form.creditAmount), maxRedemptions: Number(form.maxRedemptions), perUserLimit: Number(form.perUserLimit) };
|
||||
if (!String(form.expiresAt).trim()) delete body.expiresAt;
|
||||
else body.expiresAt = new Date(form.expiresAt).toISOString();
|
||||
const response = await billingAPI.createAdminRedeemCode(body);
|
||||
pending.value = null;
|
||||
if (!response.ok) {
|
||||
error.value = response.error ?? `HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
createdCode.value = response.data?.redeemCode ?? null;
|
||||
form.displayName = "";
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function setStatus(codeId: string | undefined, status: string): Promise<void> {
|
||||
if (!codeId) return;
|
||||
pending.value = `status:${codeId}`;
|
||||
const response = await billingAPI.updateAdminRedeemCode(codeId, { status });
|
||||
pending.value = null;
|
||||
if (!response.ok) {
|
||||
error.value = response.error ?? `HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown): string {
|
||||
const number = Number(value ?? 0);
|
||||
if (!Number.isFinite(number)) return "0";
|
||||
return new Intl.NumberFormat("zh-CN").format(number);
|
||||
}
|
||||
|
||||
function formatDate(value: unknown): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "-";
|
||||
const date = new Date(text);
|
||||
if (Number.isNaN(date.getTime())) return text;
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="route-stack">
|
||||
<PageHeader eyebrow="Admin" title="兑换与订阅" description="管理员生成兑换码并审计 redemption;真实支付 provider 未配置时只显示 placeholder。" />
|
||||
<EmptyState v-if="loading" title="正在加载兑换码" description="正在读取 user-billing redeem admin API。" />
|
||||
<template v-else>
|
||||
<p v-if="error" class="form-error admin-action-error">{{ error }}</p>
|
||||
<section class="data-panel">
|
||||
<header class="panel-header"><h2>创建兑换码</h2><small>code 只在创建响应中显示一次</small></header>
|
||||
<p v-if="createdCode" class="muted-box">新兑换码:<code>{{ createdCode }}</code></p>
|
||||
<div class="form-grid two-col">
|
||||
<label>名称<input v-model="form.displayName" class="input" placeholder="Campaign / manual recharge" /></label>
|
||||
<label>Credits<input v-model.number="form.creditAmount" class="input" type="number" min="1" /></label>
|
||||
<label>原因<input v-model="form.reason" class="input" /></label>
|
||||
<label>最大兑换次数<input v-model.number="form.maxRedemptions" class="input" type="number" min="1" /></label>
|
||||
<label>每用户限制<input v-model.number="form.perUserLimit" class="input" type="number" min="1" /></label>
|
||||
<label>过期时间<input v-model="form.expiresAt" class="input" type="datetime-local" /></label>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" :disabled="pending === 'create'" @click="createCode">{{ pending === "create" ? "创建中" : "创建兑换码" }}</button>
|
||||
</section>
|
||||
|
||||
<section class="data-panel">
|
||||
<header class="panel-header"><h2>兑换码</h2><small>{{ codes.length }} codes</small></header>
|
||||
<div class="admin-users-filters">
|
||||
<select v-model="filters.status" class="input" @change="loadCodes"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="expired">expired</option></select>
|
||||
<button class="btn btn-secondary" type="button" @click="loadCodes">刷新</button>
|
||||
</div>
|
||||
<table v-if="codes.length" class="data-table usage-table admin-billing-table">
|
||||
<thead><tr><th>Code</th><th>Status</th><th>Credits</th><th>Usage</th><th>Expires</th><th>Action</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="code in codes" :key="code.id">
|
||||
<td><code>{{ code.codePrefix }}</code><small>{{ code.displayName || code.id }} / {{ code.reason }}</small></td>
|
||||
<td><span class="status-pill" :data-status="code.status">{{ code.status }}</span></td>
|
||||
<td>{{ formatNumber(code.creditAmount) }}</td>
|
||||
<td>{{ formatNumber(code.redeemedCount) }} / {{ formatNumber(code.maxRedemptions) }}<small>per user {{ formatNumber(code.perUserLimit) }}</small></td>
|
||||
<td>{{ formatDate(code.expiresAt) }}</td>
|
||||
<td><button class="table-action" type="button" :disabled="pending === `status:${code.id}`" @click="setStatus(code.id, code.status === 'disabled' ? 'active' : 'disabled')">{{ code.status === "disabled" ? "启用" : "禁用" }}</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted-box">没有匹配兑换码。</p>
|
||||
</section>
|
||||
|
||||
<section class="data-panel">
|
||||
<header class="panel-header"><h2>兑换记录</h2><small>{{ redemptions.length }} rows</small></header>
|
||||
<div class="admin-users-filters">
|
||||
<input v-model="filters.userId" class="input" placeholder="user id" @keyup.enter="loadRedemptions" />
|
||||
<input v-model="filters.codeId" class="input" placeholder="code id" @keyup.enter="loadRedemptions" />
|
||||
<button class="btn btn-secondary" type="button" @click="loadRedemptions">过滤</button>
|
||||
</div>
|
||||
<table v-if="redemptions.length" class="data-table usage-table admin-billing-table">
|
||||
<thead><tr><th>Time</th><th>User</th><th>Code</th><th>Credits</th><th>Status</th><th>Ledger</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="row in redemptions" :key="row.id">
|
||||
<td>{{ formatDate(row.createdAt) }}<small>{{ row.id }}</small></td>
|
||||
<td><code>{{ row.userId }}</code></td>
|
||||
<td><code>{{ row.codePrefix }}</code><small>{{ row.codeId }}</small></td>
|
||||
<td>{{ formatNumber(row.creditAmount) }}</td>
|
||||
<td><span class="status-pill" :data-status="row.status">{{ row.status }}</span></td>
|
||||
<td><code>{{ row.ledgerId || "-" }}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted-box">暂无兑换记录。</p>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { billingAPI, usageAPI } from "@/api";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import type { PaymentSummaryResponse, RedeemResponse, SubscriptionSummaryResponse, UsageSummary } from "@/types";
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const pending = ref(false);
|
||||
const redeemError = ref<string | null>(null);
|
||||
const redeemResult = ref<RedeemResponse | null>(null);
|
||||
const summary = ref<UsageSummary | null>(null);
|
||||
const subscription = ref<SubscriptionSummaryResponse | null>(null);
|
||||
const payment = ref<PaymentSummaryResponse | null>(null);
|
||||
const form = reactive({ code: "", idempotencyKey: "" });
|
||||
|
||||
const credits = computed(() => summary.value?.credits ?? {});
|
||||
const entitlements = computed(() => subscription.value?.entitlements ?? summary.value?.entitlements ?? []);
|
||||
const paymentProvider = computed(() => payment.value?.provider ?? subscription.value?.payment ?? {});
|
||||
|
||||
onMounted(refreshAll);
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
loading.value = true;
|
||||
const [usage, subs, pay] = await Promise.all([usageAPI.summary(), billingAPI.subscriptionSummary(), billingAPI.paymentSummary()]);
|
||||
summary.value = usage.ok ? usage.data : null;
|
||||
subscription.value = subs.ok ? subs.data : null;
|
||||
payment.value = pay.ok ? pay.data : null;
|
||||
error.value = usage.ok || subs.ok || pay.ok ? null : usage.error ?? subs.error ?? pay.error ?? "账务接口不可用";
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function redeem(): Promise<void> {
|
||||
pending.value = true;
|
||||
redeemError.value = null;
|
||||
redeemResult.value = null;
|
||||
const response = await billingAPI.redeem({ code: form.code.trim(), idempotencyKey: form.idempotencyKey.trim() || undefined });
|
||||
pending.value = false;
|
||||
if (!response.ok) {
|
||||
redeemError.value = response.error ?? `HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
redeemResult.value = response.data;
|
||||
form.code = "";
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown): string {
|
||||
const number = Number(value ?? 0);
|
||||
if (!Number.isFinite(number)) return "0";
|
||||
return new Intl.NumberFormat("zh-CN").format(number);
|
||||
}
|
||||
|
||||
function providerStatus(value: Record<string, unknown>): string {
|
||||
return String(value.status ?? (value.configured ? "configured" : "unconfigured"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="route-stack">
|
||||
<PageHeader eyebrow="Billing" title="充值与订阅" description="兑换码、订阅计划和支付状态均由 PK01 user-billing authority 返回。" />
|
||||
<EmptyState v-if="loading" title="正在加载账务" description="正在读取余额、订阅和支付状态。" />
|
||||
<EmptyState v-else-if="error" title="账务接口不可用" :description="error" />
|
||||
<template v-else>
|
||||
<div class="overview-grid usage-metrics">
|
||||
<article class="metric-card"><span>可用 credit</span><strong>{{ formatNumber(credits.available) }}</strong></article>
|
||||
<article class="metric-card"><span>余额</span><strong>{{ formatNumber(credits.balance) }}</strong></article>
|
||||
<article class="metric-card"><span>Plan</span><strong>{{ subscription?.plan?.displayName || subscription?.planId || summary?.planId || "default" }}</strong></article>
|
||||
<article class="metric-card"><span>Payment</span><strong>{{ providerStatus(paymentProvider) }}</strong></article>
|
||||
</div>
|
||||
|
||||
<section class="data-panel billing-redeem-panel">
|
||||
<header class="panel-header"><h2>兑换码</h2><small>redeem/manual recharge</small></header>
|
||||
<p v-if="redeemError" class="form-error admin-action-error">{{ redeemError }}</p>
|
||||
<p v-if="redeemResult?.result" class="muted-box">已兑换 {{ formatNumber(redeemResult.result.creditAmount) }} credits,余额 {{ formatNumber(redeemResult.result.balanceBefore) }} -> {{ formatNumber(redeemResult.result.balanceAfter) }}。</p>
|
||||
<div class="form-grid two-col">
|
||||
<label class="span-2">Code<input v-model="form.code" class="input" autocomplete="one-time-code" placeholder="hwl_redeem_..." @keyup.enter="redeem" /></label>
|
||||
<label class="span-2">Idempotency key<input v-model="form.idempotencyKey" class="input" placeholder="可选;重复提交时防重" /></label>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" :disabled="pending || !form.code.trim()" @click="redeem">{{ pending ? "兑换中" : "兑换" }}</button>
|
||||
</section>
|
||||
|
||||
<section class="data-panel">
|
||||
<header class="panel-header"><h2>订阅计划</h2><small>{{ subscription?.subscriptions?.length ?? 0 }} records</small></header>
|
||||
<table v-if="entitlements.length" class="data-table usage-table">
|
||||
<thead><tr><th>Resource</th><th>Status</th><th>Monthly</th><th>Concurrent</th><th>RPM</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in entitlements" :key="item.resourceType">
|
||||
<td><code>{{ item.resourceType }}</code></td>
|
||||
<td><span class="status-pill" :data-status="item.enabled ? 'active' : 'disabled'">{{ item.enabled ? "enabled" : "disabled" }}</span></td>
|
||||
<td>{{ formatNumber(item.monthlyUsageCredits) }} / {{ item.monthlyQuotaUnlimited ? "unlimited" : formatNumber(item.monthlyQuotaCredits) }}</td>
|
||||
<td>{{ formatNumber(item.activeReservations) }} / {{ item.concurrencyUnlimited ? "unlimited" : formatNumber(item.maxConcurrentReservations) }}</td>
|
||||
<td>{{ formatNumber(item.rpmUsed) }} / {{ item.rpmUnlimited ? "unlimited" : formatNumber(item.rpmLimit) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted-box">当前 plan 没有 entitlement 记录。</p>
|
||||
</section>
|
||||
|
||||
<section class="data-panel">
|
||||
<header class="panel-header"><h2>支付 Provider</h2><span class="status-pill" :data-status="providerStatus(paymentProvider)">{{ providerStatus(paymentProvider) }}</span></header>
|
||||
<p class="muted-box">{{ paymentProvider.message || "真实支付 provider 尚未配置;当前只能使用兑换码或 admin 手动充值。" }}</p>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user