133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
const DEFAULT_TIMEOUT_MS = 5000;
|
|
|
|
export function createUserBillingClient({ env = process.env, fetchImpl = fetch } = {}) {
|
|
const baseUrl = normalizeBaseUrl(firstText(
|
|
env.HWLAB_USER_BILLING_URL,
|
|
env.HWLAB_USER_BILLING_BASE_URL,
|
|
env.HWLAB_USER_BILLING_INTERNAL_URL
|
|
));
|
|
const internalToken = text(env.HWLAB_USER_BILLING_INTERNAL_TOKEN);
|
|
const timeoutMs = positiveInteger(env.HWLAB_USER_BILLING_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
|
|
|
|
async function requestJson(path, { method = "GET", body = null, bearerToken = "" } = {}) {
|
|
if (!baseUrl) {
|
|
return { ok: false, status: 503, error: { code: "user_billing_not_configured", message: "HWLAB user-billing URL is not configured" } };
|
|
}
|
|
const headers = {
|
|
accept: "application/json"
|
|
};
|
|
if (body !== null) headers["content-type"] = "application/json";
|
|
const token = text(bearerToken);
|
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
if (internalToken) headers["x-hwlab-internal-token"] = internalToken;
|
|
try {
|
|
const init = {
|
|
method,
|
|
headers,
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
...(body !== null ? { body: JSON.stringify(body) } : {})
|
|
};
|
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
...init
|
|
});
|
|
const raw = await response.text();
|
|
const payload = parseJson(raw, null);
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false,
|
|
status: response.status,
|
|
body: payload,
|
|
error: normalizeUserBillingError(payload, response.status)
|
|
};
|
|
}
|
|
return { ok: true, status: response.status, body: payload ?? {} };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
status: 503,
|
|
error: {
|
|
code: "user_billing_unreachable",
|
|
message: error instanceof Error ? error.message : String(error)
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
async function post(path, body = {}) {
|
|
return requestJson(path, { method: "POST", body });
|
|
}
|
|
|
|
return {
|
|
configured: Boolean(baseUrl),
|
|
baseUrl,
|
|
async introspect(token) {
|
|
return post("/internal/auth/introspect", { token });
|
|
},
|
|
async billingPreflight(body) {
|
|
return post("/internal/billing/preflight", body);
|
|
},
|
|
async billingRecord(body) {
|
|
return post("/internal/billing/record", body);
|
|
},
|
|
async billingSummary(token, { limit = 20 } = {}) {
|
|
const query = new URLSearchParams({ limit: String(limit) });
|
|
return requestJson(`/v1/billing/summary?${query.toString()}`, { method: "GET", bearerToken: token });
|
|
},
|
|
async adminBillingSummary({ limit = 50 } = {}) {
|
|
const query = new URLSearchParams({ limit: String(limit) });
|
|
return requestJson(`/internal/admin/billing/summary?${query.toString()}`, { method: "GET" });
|
|
},
|
|
async updateAdminUserStatus(userId, status) {
|
|
return post("/internal/admin/users/status", { userId, status });
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
const input = text(value);
|
|
if (!input) return "";
|
|
try {
|
|
const url = new URL(input);
|
|
url.pathname = url.pathname.replace(/\/+$/u, "");
|
|
url.search = "";
|
|
url.hash = "";
|
|
return url.toString().replace(/\/+$/u, "");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function normalizeUserBillingError(payload, status) {
|
|
const error = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.error : null;
|
|
return {
|
|
code: text(error?.code) || (status === 402 ? "insufficient_credits" : "user_billing_request_failed"),
|
|
message: text(error?.message) || `user-billing request failed with HTTP ${status}`
|
|
};
|
|
}
|
|
|
|
function positiveInteger(value, fallback) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function firstText(...values) {
|
|
for (const value of values) {
|
|
const current = text(value);
|
|
if (current) return current;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function text(value) {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function parseJson(value, fallback) {
|
|
if (!value) return fallback;
|
|
try {
|
|
return JSON.parse(String(value));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|