@openaisdk/billing-sdk-node 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,301 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+ const SDK_VERSION = '1.3.0';
3
+ const SDK_PACKAGE_NAME = '@openaisdk/billing-sdk-node';
4
+ const DEFAULT_BASE_URL = 'https://billing.example.com';
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+ const REQUEST_ID_HEADER = 'MegaBilling-Request-Id';
7
+ const WEBHOOK_SIGNATURE_HEADER = 'x-billing-signature';
8
+ const WEBHOOK_SIGNATURE_SCHEME = 'v1';
9
+ const WEBHOOK_DEFAULT_TOLERANCE_SECONDS = 300;
10
+ const NETWORK_ERROR_MESSAGE = 'Network request failed. Check connectivity or BILLING_API_URL and retry.';
11
+ export const BILLING_WEBHOOK_EVENT_TYPES = [
12
+ 'billing.test',
13
+ 'subscription.created',
14
+ 'subscription.activated',
15
+ 'subscription.renewed',
16
+ 'subscription.plan_changed',
17
+ 'subscription.change_scheduled',
18
+ 'subscription.change_applied',
19
+ 'subscription.change_canceled',
20
+ 'subscription.past_due',
21
+ 'subscription.cancellation_scheduled',
22
+ 'subscription.canceled',
23
+ 'subscription.paused',
24
+ 'subscription.resumed',
25
+ 'subscription.trial_started',
26
+ 'invoice.finalized',
27
+ 'invoice.paid',
28
+ 'invoice.voided',
29
+ 'invoice.credit_note_issued',
30
+ 'payment.failed',
31
+ 'payment.refunded',
32
+ 'entitlement.updated',
33
+ 'entitlement.granted',
34
+ 'entitlement.revoked',
35
+ ];
36
+ export class BillingError extends Error {
37
+ code;
38
+ status;
39
+ requestId;
40
+ param;
41
+ constructor(options) {
42
+ super(options.message);
43
+ this.name = 'BillingError';
44
+ this.code = options.code;
45
+ this.status = options.status;
46
+ this.requestId = options.requestId;
47
+ this.param = options.param;
48
+ }
49
+ }
50
+ export class BillingWebhookVerificationError extends Error {
51
+ code;
52
+ constructor(code, message) {
53
+ super(message);
54
+ this.name = 'BillingWebhookVerificationError';
55
+ this.code = code;
56
+ }
57
+ }
58
+ export class MegaBilling {
59
+ apiKey;
60
+ baseUrl;
61
+ timeoutMs;
62
+ fetchImpl;
63
+ customers = {
64
+ create: (params, options) => this.request('POST', '/v1/customers', params, options),
65
+ retrieve: (customerId, options) => this.request('GET', `/v1/customers/${encodeURIComponent(customerId)}`, undefined, options),
66
+ list: (params, options) => this.request('GET', withQuery('/v1/customers', params), undefined, options),
67
+ update: (customerId, payload, options) => this.request('PATCH', `/v1/customers/${encodeURIComponent(customerId)}`, payload, options),
68
+ };
69
+ subscriptions = {
70
+ retrieve: (subscriptionId, options) => this.request('GET', `/v1/subscriptions/${encodeURIComponent(subscriptionId)}`, undefined, options),
71
+ list: (params, options) => this.request('GET', withQuery('/v1/subscriptions', params), undefined, options),
72
+ cancel: (subscriptionId, payload, options) => this.request('POST', `/v1/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`, payload ?? {}, options),
73
+ };
74
+ invoices = {
75
+ retrieve: (invoiceId, options) => this.request('GET', `/v1/invoices/${encodeURIComponent(invoiceId)}`, undefined, options),
76
+ listCreditNotes: (invoiceId, options) => this.request('GET', `/v1/invoices/${encodeURIComponent(invoiceId)}/credit-notes`, undefined, options),
77
+ };
78
+ checkout = {
79
+ sessions: {
80
+ create: (params, options) => this.request('POST', '/v1/checkout/sessions', params, options),
81
+ },
82
+ };
83
+ access = {
84
+ retrieve: (customerId, options) => this.request('GET', `/v1/customers/${encodeURIComponent(customerId)}/access`, undefined, options),
85
+ };
86
+ meterEvents = {
87
+ create: (params, options) => this.request('POST', '/v1/billing/meter-events', params, options),
88
+ summary: (meterId, params, options) => this.request('GET', withQuery(`/v1/billing/meters/${encodeURIComponent(meterId)}/event-summaries`, params), undefined, options),
89
+ };
90
+ webhooks = {
91
+ constructEvent: (payload, signatureHeader, secret, options) => constructWebhookEvent(payload, signatureHeader, secret, options),
92
+ generateTestHeaderString: (payload, secret, timestamp = Math.floor(Date.now() / 1000)) => generateWebhookTestHeaderString(payload, secret, timestamp),
93
+ };
94
+ constructor(options) {
95
+ if (!options.apiKey?.trim()) {
96
+ throw new Error('MegaBilling requires a non-empty apiKey');
97
+ }
98
+ this.apiKey = options.apiKey;
99
+ this.baseUrl = normalizeBaseUrl(options.baseUrl ?? process.env.BILLING_API_URL ?? DEFAULT_BASE_URL);
100
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
101
+ this.fetchImpl = options.fetch ?? fetch;
102
+ }
103
+ async request(method, path, body, options) {
104
+ const timeoutController = new AbortController();
105
+ let didTimeout = false;
106
+ const timeoutId = setTimeout(() => {
107
+ didTimeout = true;
108
+ timeoutController.abort();
109
+ }, this.timeoutMs);
110
+ const signal = options?.signal
111
+ ? AbortSignal.any([options.signal, timeoutController.signal])
112
+ : timeoutController.signal;
113
+ try {
114
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
115
+ method,
116
+ headers: this.buildHeaders(body, options),
117
+ body: body === undefined ? undefined : JSON.stringify(body),
118
+ signal,
119
+ });
120
+ const requestId = response.headers.get(REQUEST_ID_HEADER);
121
+ const payload = (await safeJson(response));
122
+ if (!response.ok) {
123
+ throw toBillingError(response.status, payload, requestId);
124
+ }
125
+ return withRequestId((payload ?? {}), requestId);
126
+ }
127
+ catch (error) {
128
+ if (error instanceof BillingError) {
129
+ throw error;
130
+ }
131
+ if (didTimeout) {
132
+ throw new BillingError({
133
+ code: 'request_timeout',
134
+ message: `Request timed out after ${this.timeoutMs}ms`,
135
+ status: 408,
136
+ requestId: null,
137
+ });
138
+ }
139
+ throw new BillingError({
140
+ code: 'network_error',
141
+ message: NETWORK_ERROR_MESSAGE,
142
+ status: 0,
143
+ requestId: null,
144
+ });
145
+ }
146
+ finally {
147
+ clearTimeout(timeoutId);
148
+ }
149
+ }
150
+ buildHeaders(body, options) {
151
+ const headers = new Headers();
152
+ headers.set('Accept', 'application/json');
153
+ headers.set('Authorization', `Bearer ${this.apiKey}`);
154
+ headers.set('User-Agent', `${SDK_PACKAGE_NAME}/${SDK_VERSION}`);
155
+ if (body !== undefined) {
156
+ headers.set('Content-Type', 'application/json');
157
+ }
158
+ const idempotencyKey = getIdempotencyKey(options);
159
+ if (idempotencyKey) {
160
+ headers.set('Idempotency-Key', idempotencyKey);
161
+ }
162
+ return headers;
163
+ }
164
+ }
165
+ function getIdempotencyKey(options) {
166
+ if (!options || !('idempotencyKey' in options)) {
167
+ return undefined;
168
+ }
169
+ return options.idempotencyKey;
170
+ }
171
+ function withQuery(path, params) {
172
+ const search = new URLSearchParams();
173
+ for (const [key, value] of Object.entries(params ?? {})) {
174
+ if (value !== undefined) {
175
+ search.set(key, String(value));
176
+ }
177
+ }
178
+ const query = search.toString();
179
+ return query ? `${path}?${query}` : path;
180
+ }
181
+ function normalizeBaseUrl(value) {
182
+ return value.replace(/\/+$/, '');
183
+ }
184
+ async function safeJson(response) {
185
+ const text = await response.text();
186
+ if (!text) {
187
+ return null;
188
+ }
189
+ try {
190
+ return JSON.parse(text);
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ }
196
+ function toBillingError(status, payload, headerRequestId) {
197
+ const apiError = payload && typeof payload === 'object' && 'error' in payload
198
+ ? payload.error
199
+ : undefined;
200
+ return new BillingError({
201
+ code: apiError?.code ?? `http_${status}`,
202
+ message: apiError?.message ?? `Request failed with status ${status}`,
203
+ status,
204
+ requestId: headerRequestId ?? null,
205
+ param: apiError?.param,
206
+ });
207
+ }
208
+ function withRequestId(payload, requestId) {
209
+ return {
210
+ ...payload,
211
+ requestId,
212
+ };
213
+ }
214
+ function constructWebhookEvent(payload, signatureHeader, secret, options) {
215
+ if (!signatureHeader?.trim()) {
216
+ throw new BillingWebhookVerificationError('webhook_header_invalid', `Missing ${WEBHOOK_SIGNATURE_HEADER} header`);
217
+ }
218
+ if (!secret.trim()) {
219
+ throw new BillingWebhookVerificationError('webhook_signature_invalid', 'Webhook secret must be a non-empty string');
220
+ }
221
+ const payloadString = toWebhookPayloadString(payload);
222
+ const parsedHeader = parseWebhookSignatureHeader(signatureHeader);
223
+ const expectedSignature = computeWebhookSignature(secret, payloadString, parsedHeader.timestamp);
224
+ const isValid = parsedHeader.signatures.some((signature) => secureCompareHex(signature, expectedSignature));
225
+ if (!isValid) {
226
+ throw new BillingWebhookVerificationError('webhook_signature_invalid', 'No signatures found matching the expected signature for payload.');
227
+ }
228
+ const receivedAtMs = options?.receivedAt instanceof Date
229
+ ? options.receivedAt.getTime()
230
+ : typeof options?.receivedAt === 'number'
231
+ ? options.receivedAt
232
+ : Date.now();
233
+ const toleranceSeconds = options?.toleranceSeconds ?? WEBHOOK_DEFAULT_TOLERANCE_SECONDS;
234
+ const ageSeconds = Math.abs(Math.floor(receivedAtMs / 1000) - parsedHeader.timestamp);
235
+ if (toleranceSeconds > 0 && ageSeconds > toleranceSeconds) {
236
+ throw new BillingWebhookVerificationError('webhook_timestamp_expired', 'Webhook timestamp is outside the tolerance window.');
237
+ }
238
+ let parsedPayload;
239
+ try {
240
+ parsedPayload = JSON.parse(payloadString);
241
+ }
242
+ catch {
243
+ throw new BillingWebhookVerificationError('webhook_payload_invalid', 'Webhook payload must be valid JSON.');
244
+ }
245
+ if (!parsedPayload ||
246
+ typeof parsedPayload !== 'object' ||
247
+ !('id' in parsedPayload) ||
248
+ !('type' in parsedPayload)) {
249
+ throw new BillingWebhookVerificationError('webhook_payload_invalid', 'Webhook payload is missing the required event envelope fields.');
250
+ }
251
+ return parsedPayload;
252
+ }
253
+ function generateWebhookTestHeaderString(payload, secret, timestamp = Math.floor(Date.now() / 1000)) {
254
+ const payloadString = toWebhookPayloadString(payload);
255
+ const signature = computeWebhookSignature(secret, payloadString, timestamp);
256
+ return `t=${timestamp},${WEBHOOK_SIGNATURE_SCHEME}=${signature}`;
257
+ }
258
+ function toWebhookPayloadString(payload) {
259
+ if (typeof payload === 'string') {
260
+ return payload;
261
+ }
262
+ if (payload instanceof Uint8Array) {
263
+ return new TextDecoder().decode(payload);
264
+ }
265
+ return new TextDecoder().decode(new Uint8Array(payload));
266
+ }
267
+ function parseWebhookSignatureHeader(signatureHeader) {
268
+ const parts = signatureHeader.split(',').map((part) => part.trim());
269
+ const timestampPart = parts.find((part) => part.startsWith('t='));
270
+ const signatures = parts
271
+ .filter((part) => part.startsWith(`${WEBHOOK_SIGNATURE_SCHEME}=`))
272
+ .map((part) => part.slice(`${WEBHOOK_SIGNATURE_SCHEME}=`.length))
273
+ .filter(Boolean);
274
+ const timestamp = timestampPart ? Number.parseInt(timestampPart.slice(2), 10) : Number.NaN;
275
+ if (!Number.isFinite(timestamp) || signatures.length === 0) {
276
+ throw new BillingWebhookVerificationError('webhook_header_invalid', 'Webhook signature header must contain a valid t= timestamp and at least one v1 signature.');
277
+ }
278
+ return { timestamp, signatures };
279
+ }
280
+ function computeWebhookSignature(secret, payload, timestamp) {
281
+ return createHmac('sha256', secret).update(`${timestamp}.${payload}`, 'utf8').digest('hex');
282
+ }
283
+ function secureCompareHex(left, right) {
284
+ const leftBytes = hexToBytes(left);
285
+ const rightBytes = hexToBytes(right);
286
+ if (leftBytes.length === 0 || leftBytes.length !== rightBytes.length) {
287
+ return false;
288
+ }
289
+ return timingSafeEqual(leftBytes, rightBytes);
290
+ }
291
+ function hexToBytes(value) {
292
+ if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) {
293
+ return new Uint8Array();
294
+ }
295
+ const bytes = new Uint8Array(value.length / 2);
296
+ for (let index = 0; index < value.length; index += 2) {
297
+ bytes[index / 2] = Number.parseInt(value.slice(index, index + 2), 16);
298
+ }
299
+ return bytes;
300
+ }
301
+ export { DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, REQUEST_ID_HEADER, SDK_PACKAGE_NAME, SDK_VERSION, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, };
@@ -0,0 +1,60 @@
1
+ import { MegaBilling } from '../src/index.js';
2
+
3
+ const apiKey = process.env.BILLING_API_KEY;
4
+ const appUrl = process.env.APP_URL ?? 'http://localhost:3000';
5
+ const workspaceId = process.env.WORKSPACE_ID ?? 'ws_quickstart';
6
+ const workspaceEmail = process.env.WORKSPACE_EMAIL ?? 'owner@example.com';
7
+ const workspaceName = process.env.WORKSPACE_NAME ?? 'Quickstart Workspace';
8
+ const priceId = process.env.BILLING_PRICE_ID ?? 'price_pro_monthly';
9
+
10
+ if (!apiKey) {
11
+ throw new Error('Set BILLING_API_KEY before running the quickstart');
12
+ }
13
+
14
+ const billing = new MegaBilling({
15
+ apiKey,
16
+ });
17
+
18
+ const customer = await billing.customers.create(
19
+ {
20
+ externalType: 'workspace',
21
+ externalId: workspaceId,
22
+ email: workspaceEmail,
23
+ name: workspaceName,
24
+ },
25
+ {
26
+ idempotencyKey: `customer:${workspaceId}`,
27
+ }
28
+ );
29
+
30
+ const checkout = await billing.checkout.sessions.create(
31
+ {
32
+ customer: customer.id,
33
+ price: priceId,
34
+ successUrl: `${appUrl}/billing/success`,
35
+ cancelUrl: `${appUrl}/billing`,
36
+ },
37
+ {
38
+ idempotencyKey: `checkout:${workspaceId}:${priceId}:v1`,
39
+ }
40
+ );
41
+
42
+ const access = await billing.access.retrieve(customer.id);
43
+
44
+ console.log(
45
+ JSON.stringify(
46
+ {
47
+ customerId: customer.id,
48
+ customerLivemode: customer.livemode,
49
+ customerRequestId: customer.requestId,
50
+ confirmationUrl: checkout.confirmationUrl,
51
+ checkoutLivemode: checkout.livemode,
52
+ checkoutRequestId: checkout.requestId,
53
+ accessStatus: access.status,
54
+ accessLivemode: access.livemode,
55
+ accessRequestId: access.requestId,
56
+ },
57
+ null,
58
+ 2
59
+ )
60
+ );
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@openaisdk/billing-sdk-node",
3
+ "version": "1.3.0",
4
+ "description": "Handwritten public Node SDK for Mega-Billing Wave 1 API",
5
+ "license": "MIT",
6
+ "author": "Anatoliy Tukov <openaisdk@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/MegaRetroHQ/saas-billing.git",
10
+ "directory": "packages/billing-sdk-node"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/MegaRetroHQ/saas-billing/issues"
14
+ },
15
+ "homepage": "https://www.npmjs.com/package/@openaisdk/billing-sdk-node",
16
+ "keywords": [
17
+ "billing",
18
+ "sdk",
19
+ "node",
20
+ "typescript",
21
+ "checkout",
22
+ "saas",
23
+ "megabilling"
24
+ ],
25
+ "type": "module",
26
+ "main": "dist/index.js",
27
+ "types": "dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "sideEffects": false,
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "examples/quickstart.ts"
39
+ ],
40
+ "engines": {
41
+ "node": ">=20"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "clean": "rm -rf dist",
49
+ "lint": "eslint .",
50
+ "test": "pnpm run build && node --test ./test/**/*.test.mjs",
51
+ "quickstart": "tsx ./examples/quickstart.ts",
52
+ "prepack": "pnpm run test"
53
+ },
54
+ "devDependencies": {
55
+ "@saas-billing/eslint-config": "workspace:*",
56
+ "@saas-billing/typescript-config": "workspace:*",
57
+ "@types/node": "^20",
58
+ "eslint": "^9.31.0",
59
+ "tsx": "^4.21.0",
60
+ "typescript": "^5.7.0"
61
+ }
62
+ }