@panoptic-it-solutions/quickbooks-client 0.1.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.mjs ADDED
@@ -0,0 +1,557 @@
1
+ // src/errors.ts
2
+ var QB_ERROR_CODES = {
3
+ TOKEN_EXPIRED: "QB_TOKEN_EXPIRED",
4
+ REFRESH_FAILED: "QB_REFRESH_FAILED",
5
+ UNAUTHORIZED: "QB_UNAUTHORIZED",
6
+ INVALID_REALM: "QB_INVALID_REALM",
7
+ API_ERROR: "QB_API_ERROR",
8
+ RATE_LIMIT: "QB_RATE_LIMIT",
9
+ NETWORK_ERROR: "QB_NETWORK_ERROR",
10
+ INVALID_CONFIG: "QB_INVALID_CONFIG",
11
+ TOKEN_STORE_ERROR: "QB_TOKEN_STORE_ERROR"
12
+ };
13
+ var QuickBooksError = class extends Error {
14
+ constructor(message, code, status, details) {
15
+ super(message);
16
+ this.code = code;
17
+ this.status = status;
18
+ this.details = details;
19
+ this.name = "QuickBooksError";
20
+ }
21
+ };
22
+ function handleQuickBooksError(error) {
23
+ if (error instanceof QuickBooksError) {
24
+ return error;
25
+ }
26
+ if (error instanceof TypeError && error.message.includes("fetch")) {
27
+ return new QuickBooksError(
28
+ "Network error connecting to QuickBooks API",
29
+ QB_ERROR_CODES.NETWORK_ERROR,
30
+ 0,
31
+ error
32
+ );
33
+ }
34
+ if (typeof error === "object" && error !== null) {
35
+ const err = error;
36
+ if (typeof err.status === "number") {
37
+ if (err.status === 401) {
38
+ return new QuickBooksError(
39
+ "Unauthorized access to QuickBooks API",
40
+ QB_ERROR_CODES.UNAUTHORIZED,
41
+ 401,
42
+ error
43
+ );
44
+ }
45
+ if (err.status === 403) {
46
+ return new QuickBooksError(
47
+ "Access forbidden - check API permissions",
48
+ QB_ERROR_CODES.UNAUTHORIZED,
49
+ 403,
50
+ error
51
+ );
52
+ }
53
+ if (err.status === 429) {
54
+ return new QuickBooksError(
55
+ "QuickBooks API rate limit exceeded",
56
+ QB_ERROR_CODES.RATE_LIMIT,
57
+ 429,
58
+ error
59
+ );
60
+ }
61
+ }
62
+ const message = String(err.message || "");
63
+ if (message.includes("token expired") || message.includes("invalid_token")) {
64
+ return new QuickBooksError(
65
+ "QuickBooks token expired",
66
+ QB_ERROR_CODES.TOKEN_EXPIRED,
67
+ 401,
68
+ error
69
+ );
70
+ }
71
+ }
72
+ return new QuickBooksError(
73
+ "An unexpected error occurred with QuickBooks integration",
74
+ QB_ERROR_CODES.API_ERROR,
75
+ 500,
76
+ error
77
+ );
78
+ }
79
+
80
+ // src/oauth.ts
81
+ var ENDPOINTS = {
82
+ sandbox: {
83
+ authorize: "https://appcenter.intuit.com/connect/oauth2",
84
+ token: "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
85
+ revoke: "https://developer.api.intuit.com/v2/oauth2/tokens/revoke"
86
+ },
87
+ production: {
88
+ authorize: "https://appcenter.intuit.com/connect/oauth2",
89
+ token: "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
90
+ revoke: "https://developer.api.intuit.com/v2/oauth2/tokens/revoke"
91
+ }
92
+ };
93
+ var DEFAULT_SCOPES = ["com.intuit.quickbooks.accounting"];
94
+ function generateAuthUrl(config, state) {
95
+ const env = config.environment || "production";
96
+ const scopes = config.scopes || DEFAULT_SCOPES;
97
+ const params = new URLSearchParams({
98
+ client_id: config.clientId,
99
+ redirect_uri: config.redirectUri,
100
+ response_type: "code",
101
+ scope: scopes.join(" "),
102
+ state
103
+ });
104
+ return `${ENDPOINTS[env].authorize}?${params.toString()}`;
105
+ }
106
+ async function exchangeCodeForTokens(config, code, realmId) {
107
+ const env = config.environment || "production";
108
+ const credentials = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
109
+ const response = await fetch(ENDPOINTS[env].token, {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/x-www-form-urlencoded",
113
+ Authorization: `Basic ${credentials}`,
114
+ Accept: "application/json"
115
+ },
116
+ body: new URLSearchParams({
117
+ grant_type: "authorization_code",
118
+ code,
119
+ redirect_uri: config.redirectUri
120
+ }).toString()
121
+ });
122
+ if (!response.ok) {
123
+ const errorData = await response.json().catch(() => ({}));
124
+ throw new QuickBooksError(
125
+ `Failed to exchange code for tokens: ${response.status}`,
126
+ QB_ERROR_CODES.UNAUTHORIZED,
127
+ response.status,
128
+ errorData
129
+ );
130
+ }
131
+ const data = await response.json();
132
+ return {
133
+ access_token: data.access_token,
134
+ refresh_token: data.refresh_token,
135
+ realm_id: realmId,
136
+ expires_at: calculateTokenExpiry(data.expires_in),
137
+ token_type: data.token_type
138
+ };
139
+ }
140
+ async function refreshTokens(config, refreshToken, realmId) {
141
+ const env = config.environment || "production";
142
+ const credentials = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
143
+ const response = await fetch(ENDPOINTS[env].token, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/x-www-form-urlencoded",
147
+ Authorization: `Basic ${credentials}`,
148
+ Accept: "application/json"
149
+ },
150
+ body: new URLSearchParams({
151
+ grant_type: "refresh_token",
152
+ refresh_token: refreshToken
153
+ }).toString()
154
+ });
155
+ if (!response.ok) {
156
+ const errorData = await response.json().catch(() => ({}));
157
+ throw new QuickBooksError(
158
+ `Failed to refresh tokens: ${response.status}`,
159
+ QB_ERROR_CODES.REFRESH_FAILED,
160
+ response.status,
161
+ errorData
162
+ );
163
+ }
164
+ const data = await response.json();
165
+ return {
166
+ access_token: data.access_token,
167
+ refresh_token: data.refresh_token,
168
+ realm_id: realmId,
169
+ expires_at: calculateTokenExpiry(data.expires_in),
170
+ token_type: data.token_type
171
+ };
172
+ }
173
+ async function revokeTokens(config, token) {
174
+ const env = config.environment || "production";
175
+ const credentials = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
176
+ const response = await fetch(ENDPOINTS[env].revoke, {
177
+ method: "POST",
178
+ headers: {
179
+ "Content-Type": "application/json",
180
+ Authorization: `Basic ${credentials}`,
181
+ Accept: "application/json"
182
+ },
183
+ body: JSON.stringify({ token })
184
+ });
185
+ if (!response.ok) {
186
+ const errorData = await response.json().catch(() => ({}));
187
+ throw new QuickBooksError(
188
+ `Failed to revoke tokens: ${response.status}`,
189
+ QB_ERROR_CODES.API_ERROR,
190
+ response.status,
191
+ errorData
192
+ );
193
+ }
194
+ }
195
+ function calculateTokenExpiry(expiresIn) {
196
+ return Math.floor(Date.now() / 1e3) + expiresIn;
197
+ }
198
+ function isTokenExpired(expiresAt, bufferSeconds = 300) {
199
+ const now = Math.floor(Date.now() / 1e3);
200
+ return now >= expiresAt - bufferSeconds;
201
+ }
202
+ function generateState() {
203
+ const array = new Uint8Array(16);
204
+ crypto.getRandomValues(array);
205
+ return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
206
+ }
207
+
208
+ // src/client.ts
209
+ var API_BASE = {
210
+ sandbox: "https://sandbox-quickbooks.api.intuit.com",
211
+ production: "https://quickbooks.api.intuit.com"
212
+ };
213
+ var RATE_LIMIT = 500;
214
+ var RATE_LIMIT_WINDOW_MS = 60 * 1e3;
215
+ var MAX_RETRIES = 3;
216
+ var INITIAL_RETRY_DELAY_MS = 1e3;
217
+ var QuickBooksClient = class {
218
+ config;
219
+ tokenStore;
220
+ requestTimestamps = [];
221
+ onLog;
222
+ constructor(options) {
223
+ this.validateConfig(options);
224
+ this.config = options;
225
+ this.tokenStore = options.tokenStore;
226
+ this.onLog = options.onLog;
227
+ }
228
+ validateConfig(options) {
229
+ if (!options.clientId) {
230
+ throw new QuickBooksError("clientId is required", QB_ERROR_CODES.INVALID_CONFIG);
231
+ }
232
+ if (!options.clientSecret) {
233
+ throw new QuickBooksError("clientSecret is required", QB_ERROR_CODES.INVALID_CONFIG);
234
+ }
235
+ if (!options.redirectUri) {
236
+ throw new QuickBooksError("redirectUri is required", QB_ERROR_CODES.INVALID_CONFIG);
237
+ }
238
+ if (!options.tokenStore) {
239
+ throw new QuickBooksError("tokenStore is required", QB_ERROR_CODES.INVALID_CONFIG);
240
+ }
241
+ }
242
+ log(level, message, data) {
243
+ if (this.onLog) {
244
+ this.onLog(level, message, data);
245
+ }
246
+ }
247
+ /**
248
+ * Rate limiting - ensures we don't exceed 500 requests/minute
249
+ */
250
+ async checkRateLimit() {
251
+ const now = Date.now();
252
+ this.requestTimestamps = this.requestTimestamps.filter(
253
+ (ts) => now - ts < RATE_LIMIT_WINDOW_MS
254
+ );
255
+ if (this.requestTimestamps.length >= RATE_LIMIT) {
256
+ const oldestTimestamp = this.requestTimestamps[0];
257
+ const waitTime = RATE_LIMIT_WINDOW_MS - (now - oldestTimestamp) + 100;
258
+ this.log("warn", `Rate limit reached, waiting ${waitTime}ms`);
259
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
260
+ }
261
+ this.requestTimestamps.push(Date.now());
262
+ }
263
+ /**
264
+ * Get current tokens, refreshing if necessary
265
+ */
266
+ async getValidTokens() {
267
+ const tokens = await this.tokenStore.getTokens();
268
+ if (!tokens) {
269
+ throw new QuickBooksError(
270
+ "No tokens found - please connect to QuickBooks first",
271
+ QB_ERROR_CODES.UNAUTHORIZED
272
+ );
273
+ }
274
+ if (isTokenExpired(tokens.expires_at)) {
275
+ this.log("info", "Token expired, refreshing...");
276
+ try {
277
+ const newTokens = await refreshTokens(
278
+ this.config,
279
+ tokens.refresh_token,
280
+ tokens.realm_id
281
+ );
282
+ await this.tokenStore.storeTokens(newTokens);
283
+ this.log("info", "Token refreshed successfully");
284
+ return newTokens;
285
+ } catch (error) {
286
+ if (error instanceof QuickBooksError && error.status === 401) {
287
+ this.log("warn", "Refresh token invalid, clearing tokens");
288
+ await this.tokenStore.clearTokens();
289
+ }
290
+ throw error;
291
+ }
292
+ }
293
+ return tokens;
294
+ }
295
+ /**
296
+ * Make an authenticated API request with retry logic
297
+ */
298
+ async request(method, endpoint, body, retryCount = 0) {
299
+ await this.checkRateLimit();
300
+ const tokens = await this.getValidTokens();
301
+ const env = this.config.environment || "production";
302
+ const baseUrl = API_BASE[env];
303
+ const url = `${baseUrl}/v3/company/${tokens.realm_id}${endpoint}`;
304
+ this.log("debug", `${method} ${endpoint}`, { body });
305
+ const headers = {
306
+ Authorization: `Bearer ${tokens.access_token}`,
307
+ Accept: "application/json"
308
+ };
309
+ if (body) {
310
+ headers["Content-Type"] = "application/json";
311
+ }
312
+ try {
313
+ const response = await fetch(url, {
314
+ method,
315
+ headers,
316
+ body: body ? JSON.stringify(body) : void 0
317
+ });
318
+ if (response.status === 429 && retryCount < MAX_RETRIES) {
319
+ const retryAfter = response.headers.get("Retry-After");
320
+ const delay = retryAfter ? parseInt(retryAfter, 10) * 1e3 : INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount);
321
+ this.log("warn", `Rate limited, retrying in ${delay}ms (attempt ${retryCount + 1})`);
322
+ await new Promise((resolve) => setTimeout(resolve, delay));
323
+ return this.request(method, endpoint, body, retryCount + 1);
324
+ }
325
+ if (response.status === 401 && retryCount < 1) {
326
+ this.log("warn", "Got 401, attempting token refresh");
327
+ const tokens2 = await this.tokenStore.getTokens();
328
+ if (tokens2) {
329
+ const newTokens = await refreshTokens(
330
+ this.config,
331
+ tokens2.refresh_token,
332
+ tokens2.realm_id
333
+ );
334
+ await this.tokenStore.storeTokens(newTokens);
335
+ return this.request(method, endpoint, body, retryCount + 1);
336
+ }
337
+ }
338
+ if (!response.ok) {
339
+ const errorData = await response.json().catch(() => ({}));
340
+ throw { status: response.status, ...errorData };
341
+ }
342
+ return await response.json();
343
+ } catch (error) {
344
+ throw handleQuickBooksError(error);
345
+ }
346
+ }
347
+ /**
348
+ * Execute a query using QuickBooks Query Language
349
+ */
350
+ async query(sql) {
351
+ const response = await this.request("POST", "/query", void 0);
352
+ const tokens = await this.getValidTokens();
353
+ const env = this.config.environment || "production";
354
+ const baseUrl = API_BASE[env];
355
+ const url = `${baseUrl}/v3/company/${tokens.realm_id}/query`;
356
+ await this.checkRateLimit();
357
+ const fetchResponse = await fetch(url, {
358
+ method: "POST",
359
+ headers: {
360
+ Authorization: `Bearer ${tokens.access_token}`,
361
+ Accept: "application/json",
362
+ "Content-Type": "application/text"
363
+ },
364
+ body: sql
365
+ });
366
+ if (!fetchResponse.ok) {
367
+ const errorData = await fetchResponse.json().catch(() => ({}));
368
+ throw handleQuickBooksError({ status: fetchResponse.status, ...errorData });
369
+ }
370
+ const data = await fetchResponse.json();
371
+ const keys = Object.keys(data.QueryResponse).filter(
372
+ (k) => !["startPosition", "maxResults", "totalCount"].includes(k)
373
+ );
374
+ const entityKey = keys[0];
375
+ return entityKey ? data.QueryResponse[entityKey] : [];
376
+ }
377
+ // ============================================
378
+ // Invoice Methods
379
+ // ============================================
380
+ async getInvoice(id) {
381
+ const response = await this.request("GET", `/invoice/${id}`);
382
+ return response.Invoice;
383
+ }
384
+ async getInvoices(where) {
385
+ const sql = where ? `SELECT * FROM Invoice WHERE ${where}` : "SELECT * FROM Invoice";
386
+ return this.query(sql);
387
+ }
388
+ async createInvoice(invoice) {
389
+ const response = await this.request("POST", "/invoice", invoice);
390
+ return response.Invoice;
391
+ }
392
+ async updateInvoice(invoice) {
393
+ const response = await this.request("POST", "/invoice", invoice);
394
+ return response.Invoice;
395
+ }
396
+ async deleteInvoice(id, syncToken) {
397
+ await this.request("POST", "/invoice", {
398
+ Id: id,
399
+ SyncToken: syncToken
400
+ });
401
+ }
402
+ // ============================================
403
+ // Customer Methods
404
+ // ============================================
405
+ async getCustomer(id) {
406
+ const response = await this.request("GET", `/customer/${id}`);
407
+ return response.Customer;
408
+ }
409
+ async getCustomers(where) {
410
+ const sql = where ? `SELECT * FROM Customer WHERE ${where}` : "SELECT * FROM Customer";
411
+ return this.query(sql);
412
+ }
413
+ async createCustomer(customer) {
414
+ const response = await this.request("POST", "/customer", customer);
415
+ return response.Customer;
416
+ }
417
+ async updateCustomer(customer) {
418
+ const response = await this.request("POST", "/customer", customer);
419
+ return response.Customer;
420
+ }
421
+ // ============================================
422
+ // Payment Methods
423
+ // ============================================
424
+ async getPayment(id) {
425
+ const response = await this.request("GET", `/payment/${id}`);
426
+ return response.Payment;
427
+ }
428
+ async getPayments(where) {
429
+ const sql = where ? `SELECT * FROM Payment WHERE ${where}` : "SELECT * FROM Payment";
430
+ return this.query(sql);
431
+ }
432
+ async createPayment(payment) {
433
+ const response = await this.request("POST", "/payment", payment);
434
+ return response.Payment;
435
+ }
436
+ // ============================================
437
+ // Account Methods
438
+ // ============================================
439
+ async getAccount(id) {
440
+ const response = await this.request("GET", `/account/${id}`);
441
+ return response.Account;
442
+ }
443
+ async getAccounts(where) {
444
+ const sql = where ? `SELECT * FROM Account WHERE ${where}` : "SELECT * FROM Account WHERE Active = true";
445
+ return this.query(sql);
446
+ }
447
+ // ============================================
448
+ // Vendor Methods
449
+ // ============================================
450
+ async getVendor(id) {
451
+ const response = await this.request("GET", `/vendor/${id}`);
452
+ return response.Vendor;
453
+ }
454
+ async getVendors(where) {
455
+ const sql = where ? `SELECT * FROM Vendor WHERE ${where}` : "SELECT * FROM Vendor";
456
+ return this.query(sql);
457
+ }
458
+ async createVendor(vendor) {
459
+ const response = await this.request("POST", "/vendor", vendor);
460
+ return response.Vendor;
461
+ }
462
+ async updateVendor(vendor) {
463
+ const response = await this.request("POST", "/vendor", vendor);
464
+ return response.Vendor;
465
+ }
466
+ // ============================================
467
+ // Bill Methods
468
+ // ============================================
469
+ async getBill(id) {
470
+ const response = await this.request("GET", `/bill/${id}`);
471
+ return response.Bill;
472
+ }
473
+ async getBills(where) {
474
+ const sql = where ? `SELECT * FROM Bill WHERE ${where}` : "SELECT * FROM Bill";
475
+ return this.query(sql);
476
+ }
477
+ async createBill(bill) {
478
+ const response = await this.request("POST", "/bill", bill);
479
+ return response.Bill;
480
+ }
481
+ async updateBill(bill) {
482
+ const response = await this.request("POST", "/bill", bill);
483
+ return response.Bill;
484
+ }
485
+ // ============================================
486
+ // Item Methods
487
+ // ============================================
488
+ async getItem(id) {
489
+ const response = await this.request("GET", `/item/${id}`);
490
+ return response.Item;
491
+ }
492
+ async getItems(where) {
493
+ const sql = where ? `SELECT * FROM Item WHERE ${where}` : "SELECT * FROM Item WHERE Active = true";
494
+ return this.query(sql);
495
+ }
496
+ async createItem(item) {
497
+ const response = await this.request("POST", "/item", item);
498
+ return response.Item;
499
+ }
500
+ async updateItem(item) {
501
+ const response = await this.request("POST", "/item", item);
502
+ return response.Item;
503
+ }
504
+ // ============================================
505
+ // Utility Methods
506
+ // ============================================
507
+ /**
508
+ * Get company info
509
+ */
510
+ async getCompanyInfo() {
511
+ const tokens = await this.getValidTokens();
512
+ const response = await this.request(
513
+ "GET",
514
+ `/companyinfo/${tokens.realm_id}`
515
+ );
516
+ return response.CompanyInfo;
517
+ }
518
+ /**
519
+ * Check if connected to QuickBooks
520
+ */
521
+ async isConnected() {
522
+ try {
523
+ const tokens = await this.tokenStore.getTokens();
524
+ return tokens !== null;
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+ /**
530
+ * Check connection status with details
531
+ */
532
+ async getConnectionStatus() {
533
+ const tokens = await this.tokenStore.getTokens();
534
+ if (!tokens) {
535
+ return { isConnected: false, needsRefresh: false };
536
+ }
537
+ return {
538
+ isConnected: true,
539
+ needsRefresh: isTokenExpired(tokens.expires_at),
540
+ realmId: tokens.realm_id,
541
+ expiresAt: tokens.expires_at
542
+ };
543
+ }
544
+ };
545
+ export {
546
+ QB_ERROR_CODES,
547
+ QuickBooksClient,
548
+ QuickBooksError,
549
+ calculateTokenExpiry,
550
+ exchangeCodeForTokens,
551
+ generateAuthUrl,
552
+ generateState,
553
+ handleQuickBooksError,
554
+ isTokenExpired,
555
+ refreshTokens,
556
+ revokeTokens
557
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@panoptic-it-solutions/quickbooks-client",
3
+ "version": "0.1.0",
4
+ "description": "QuickBooks Online API client with OAuth 2.0, rate limiting, and typed entities",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format cjs,esm --dts",
20
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
21
+ "test": "vitest",
22
+ "test:run": "vitest run",
23
+ "lint": "biome check src",
24
+ "format": "biome format --write src",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "keywords": [
28
+ "quickbooks",
29
+ "intuit",
30
+ "oauth",
31
+ "accounting",
32
+ "api",
33
+ "client"
34
+ ],
35
+ "author": "Panoptic Technology Ltd",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/panoptic-it-solutions/quickbooks-client"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "dependencies": {},
45
+ "devDependencies": {
46
+ "@biomejs/biome": "^2.3.11",
47
+ "@types/node": "^20",
48
+ "tsup": "^8.0.0",
49
+ "typescript": "^5",
50
+ "vitest": "^2.0.0"
51
+ }
52
+ }