UNPKG

@unitpay/sdk

Version:

Official UnitPay SDK for TypeScript — Node, browser, and edge. Portal sessions, subscriptions, invoices, credits, usage tracking.

475 lines (465 loc) 14.8 kB
// src/case-convert.ts var toCamelCase = (str) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); var deepCamelCase = (obj) => { if (obj === null || obj === void 0) return obj; if (typeof obj !== "object") return obj; if (Array.isArray(obj)) return obj.map(deepCamelCase); const result = {}; for (const [key, value] of Object.entries(obj)) { if (key === "metadata" || key === "properties") { result[toCamelCase(key)] = value; } else { result[toCamelCase(key)] = deepCamelCase(value); } } return result; }; // src/errors.ts var ApiError = class _ApiError extends Error { status; code; requestId; headers; constructor(params) { super(params.message); this.name = "ApiError"; this.status = params.status; this.code = params.code; this.requestId = params.requestId; this.headers = params.headers; } toString() { return `${this.name} [${this.status}] ${this.code}: ${this.message} (requestId: ${this.requestId ?? "none"})`; } static generate(status, body, headers) { const code = body?.error?.code ?? "unknown_error"; const message = body?.error?.message ?? `Request failed with status ${status}`; const requestId = headers.get("x-request-id"); const headerRecord = {}; headers.forEach((value, key) => { headerRecord[key] = value; }); const params = { status, code, message, requestId, headers: headerRecord }; switch (status) { case 400: return new BadRequestError(params); case 401: return new AuthenticationError(params); case 404: return new NotFoundError(params); case 409: return new ConflictError(params); case 422: return new ValidationError(params); case 429: { const retryAfterHeader = headers.get("retry-after"); const retryAfter = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : null; return new RateLimitError({ ...params, retryAfter }); } default: if (status >= 500) return new InternalServerError(params); return new _ApiError(params); } } }; var BadRequestError = class extends ApiError { constructor(params) { super(params); this.name = "BadRequestError"; } }; var AuthenticationError = class extends ApiError { constructor(params) { super(params); this.name = "AuthenticationError"; } }; var NotFoundError = class extends ApiError { constructor(params) { super(params); this.name = "NotFoundError"; } }; var ConflictError = class extends ApiError { constructor(params) { super(params); this.name = "ConflictError"; } }; var ValidationError = class extends ApiError { constructor(params) { super(params); this.name = "ValidationError"; } }; var RateLimitError = class extends ApiError { retryAfter; constructor(params) { super(params); this.name = "RateLimitError"; this.retryAfter = params.retryAfter; } }; var InternalServerError = class extends ApiError { constructor(params) { super(params); this.name = "InternalServerError"; } }; var ConnectionError = class extends ApiError { constructor(message) { super({ status: 0, code: "connection_error", message, requestId: null, headers: {} }); this.name = "ConnectionError"; } }; var TimeoutError = class extends ApiError { constructor(timeoutMs) { super({ status: 0, code: "timeout_error", message: `Request timed out after ${timeoutMs}ms`, requestId: null, headers: {} }); this.name = "TimeoutError"; } }; // src/pagination.ts var PagePromise = class { fetchPage; initialPromise; constructor(fetchPage) { this.fetchPage = fetchPage; this.initialPromise = fetchPage(); } // biome-ignore lint/suspicious/noThenProperty: intentional PromiseLike implementation (Stripe SDK pattern) then(onfulfilled, onrejected) { return this.initialPromise.then(onfulfilled, onrejected); } catch(onrejected) { return this.initialPromise.catch(onrejected); } async *[Symbol.asyncIterator]() { let page = await this.initialPromise; for (const item of page.data) yield item; while (page.hasMore && page.nextCursor) { page = await this.fetchPage(page.nextCursor); for (const item of page.data) yield item; } } async toArray() { const items = []; for await (const item of this) items.push(item); return items; } }; // src/resource.ts var ApiResource = class { client; constructor(client) { this.client = client; } _get(path, query, options) { return this.client.request("GET", path, void 0, { ...options, query }); } _post(path, body, options) { return this.client.request("POST", path, body, options); } _put(path, body, options) { return this.client.request("PUT", path, body, options); } _patch(path, body, options) { return this.client.request("PATCH", path, body, options); } _delete(path, options) { return this.client.request("DELETE", path, void 0, options); } _getPage(path, params) { return new PagePromise((cursor) => { const query = { ...params }; if (cursor) query.cursor = cursor; return this.client.request("GET", path, void 0, { query }); }); } }; // src/resources/customers.ts var Customers = class extends ApiResource { create(params, options) { return this._post("/customers", params, options); } get(id, options) { return this._get(`/customers/${id}`, void 0, options); } list(params) { return this._getPage("/customers", params); } update(id, params, options) { return this._patch(`/customers/${id}`, params, options); } archive(id, options) { return this._post(`/customers/${id}/archive`, void 0, options); } listInvoices(id, params) { return this._getPage(`/customers/${id}/invoices`, params); } listPaymentMethods(id, params) { return this._getPage(`/customers/${id}/payment-methods`, params); } listCreditAccounts(id, params) { return this._getPage(`/customers/${id}/credit-accounts`, params); } /** Active entitlement matrix for the customer. */ entitlements(id, params) { return this._getPage(`/customers/${id}/entitlements`, params); } /** Does this customer have access to `featureSlug` right now? */ check(id, params, options) { return this._post(`/customers/${id}/check`, params, options); } /** Resolve up to 50 feature slugs in one round trip. */ checkBatch(id, featureSlugs, options) { return this._post( `/customers/${id}/check/batch`, { featureSlugs }, options ); } }; // src/resources/portal-sessions.ts var PortalSessions = class extends ApiResource { create(params, options) { return this._post("/portal-sessions", params, options); } revoke(params, options) { return this._post("/portal-sessions/revoke", params, options); } }; // src/resources/subscriptions.ts var Subscriptions = class extends ApiResource { create(params, options) { return this._post("/subscriptions", params, options); } get(id, options) { return this._get(`/subscriptions/${id}`, void 0, options); } list(params) { return this._getPage("/subscriptions", params); } change(id, params, options) { return this._post(`/subscriptions/${id}/change`, params, options); } cancel(id, params, options) { return this._post(`/subscriptions/${id}/cancel`, params, options); } uncancel(id, options) { return this._post(`/subscriptions/${id}/uncancel`, void 0, options); } }; // src/resources/usage.ts var Usage = class extends ApiResource { track(params, options) { const body = Array.isArray(params) ? { events: params } : params; return this._post("/usage/track", body, options); } }; // src/version.ts var SDK_VERSION = "1.32.0"; // src/client.ts var DEFAULT_BASE_URL = "https://api.useunitpay.com/v1"; var DEFAULT_TIMEOUT = 3e4; var DEFAULT_RETRIES = 2; var SDK_USER_AGENT = `unitpay-node/${SDK_VERSION}`; var readEnv = (key) => { if (typeof process !== "undefined" && process.env) { return process.env[key]; } return; }; var UnitPay = class { apiKey; baseUrl; timeout; maxRetries; fetchFn; idempotencyKeyPrefix; listeners = /* @__PURE__ */ new Map(); // ── Lazy-loaded resources ──────────────────────────────────────────────── _portalSessions; get portalSessions() { this._portalSessions ??= new PortalSessions(this); return this._portalSessions; } _customers; get customers() { this._customers ??= new Customers(this); return this._customers; } _subscriptions; get subscriptions() { this._subscriptions ??= new Subscriptions(this); return this._subscriptions; } _usage; get usage() { this._usage ??= new Usage(this); return this._usage; } constructor(config) { const apiKey = config?.apiKey ?? readEnv("UNITPAY_API_KEY"); if (!apiKey) { throw new Error( "UnitPay: `apiKey` is required. Pass it in the config or set UNITPAY_API_KEY in the environment. Portal tokens are not supported here \u2014 use `@unitpay/react` for customer-facing flows." ); } if (config?.portalToken !== void 0) { throw new Error( "UnitPay: `portalToken` is not a valid option for the server SDK. Portal tokens belong in `@unitpay/react`; pass them to `<UnitPayProvider portalToken={\u2026}>`." ); } this.apiKey = apiKey; this.baseUrl = config?.baseUrl ?? DEFAULT_BASE_URL; this.timeout = config?.timeout ?? DEFAULT_TIMEOUT; this.maxRetries = config?.retries ?? DEFAULT_RETRIES; this.fetchFn = config?.fetch ?? globalThis.fetch; this.idempotencyKeyPrefix = config?.idempotencyKeyPrefix ?? ""; } async track(...args) { return this.usage.track(...args); } /** * Entitlement gate — "does this customer have access to `featureSlug` right * now?" Read-only; deductions happen via `track`. `POST /check`. */ check(params, options) { return this.request("POST", "/check", params, options); } on(event, callback) { if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set()); this.listeners.get(event).add(callback); return this; } off(event, callback) { this.listeners.get(event)?.delete(callback); return this; } emit(event, data) { this.listeners.get(event)?.forEach((cb) => cb(data)); } async request(method, path, body, options) { const url = new URL(`${this.baseUrl}${path}`); if (options?.query) { for (const [key, value] of Object.entries(options.query)) { if (value !== void 0 && value !== null) { url.searchParams.set(key, String(value)); } } } const headers = { Authorization: `Bearer ${this.apiKey}`, "X-UnitPay-Client": SDK_USER_AGENT }; if (body !== void 0) { headers["Content-Type"] = "application/json"; } if ((method === "POST" || method === "DELETE") && this.maxRetries > 0) { const idempotencyKey = options?.idempotencyKey ?? `${this.idempotencyKeyPrefix}${crypto.randomUUID()}`; headers["Idempotency-Key"] = idempotencyKey; } const requestBody = body ? JSON.stringify(body) : void 0; let lastError; const userSignal = options?.signal; for (let attempt = 0; attempt <= this.maxRetries; attempt++) { this.emit("request", { method, path, attempt }); let timeoutId; try { const timeoutMs = options?.timeout ?? this.timeout; let controller; if (!userSignal) { controller = new AbortController(); timeoutId = setTimeout(() => controller.abort(), timeoutMs); } const signal = userSignal ?? controller.signal; const response = await this.fetchFn(url.toString(), { method, headers, body: requestBody, signal }); if (timeoutId !== void 0) clearTimeout(timeoutId); const requestId = response.headers.get("x-request-id"); this.emit("response", { status: response.status, requestId }); if (response.ok) { const text = await response.text(); if (!text) return void 0; const json = JSON.parse(text); return deepCamelCase(json); } let errorBody = null; try { errorBody = await response.json(); } catch { } const error = ApiError.generate(response.status, errorBody, response.headers); const isRetryable = response.status === 429 || response.status === 409 || response.status >= 500; if (!isRetryable || attempt === this.maxRetries) { throw error; } lastError = error; let backoffMs; if (error instanceof RateLimitError && error.retryAfter !== null) { backoffMs = Math.min(error.retryAfter * 1e3, 6e4); } else { const base = 500 * 2 ** attempt; const jitter = 0.5 + Math.random() * 0.5; backoffMs = Math.min(base * jitter, 1e4); } await new Promise((resolve) => setTimeout(resolve, backoffMs)); } catch (error) { if (timeoutId !== void 0) clearTimeout(timeoutId); if (error instanceof ApiError) throw error; if (error instanceof DOMException && error.name === "AbortError") { if (userSignal?.aborted) throw error; throw new TimeoutError(options?.timeout ?? this.timeout); } if (attempt === this.maxRetries) { throw error instanceof Error ? new ConnectionError(error.message) : new ConnectionError("Network request failed"); } lastError = error instanceof Error ? error : new Error(String(error)); const base = 500 * 2 ** attempt; const jitter = 0.5 + Math.random() * 0.5; const backoffMs = Math.min(base * jitter, 1e4); await new Promise((resolve) => setTimeout(resolve, backoffMs)); } } throw lastError ?? new ConnectionError("Request failed after all retries"); } static async verifyWebhook(body, headers, secret) { const { Webhook } = await import("svix"); const webhook = new Webhook(secret); const event = webhook.verify(body, headers); return deepCamelCase(event); } get redactedApiKey() { if (this.apiKey.length <= 16) return "***"; return `${this.apiKey.slice(0, 12)}...${this.apiKey.slice(-4)}`; } }; export { ApiError, AuthenticationError, BadRequestError, ConflictError, ConnectionError, Customers, InternalServerError, NotFoundError, PagePromise, PortalSessions, RateLimitError, SDK_VERSION, Subscriptions, TimeoutError, UnitPay, Usage, ValidationError };