UNPKG

@romanzubenko_afternoon/quickbooks

Version:
254 lines (213 loc) 7.62 kB
import querystring from 'node:querystring' import { Grant, Config, Token, grantSchema, tokenSchema } from './schemas' import { z } from 'zod' import crypto from 'crypto' export class QuickBooks { // Properties readonly clientId: string readonly clientSecret: string readonly redirectUri: string readonly responseType: string readonly authorizeEndpoint: string readonly tokenEndpoint: string readonly revokeEndpoint: string readonly userEndpoint: string readonly apiBaseUrl: string readonly scopes: { [key: string]: string } readonly accessTokenLatency: number readonly refreshTokenLatency: number readonly minorversion: string // Constructor constructor(config: Config) { this.clientId = config.clientId this.clientSecret = config.clientSecret this.redirectUri = config.redirectUri this.responseType = 'code' this.authorizeEndpoint = 'https://appcenter.intuit.com/connect/oauth2' this.tokenEndpoint = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer' this.revokeEndpoint = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke' this.userEndpoint = config.environment === 'production' ? 'https://accounts.platform.intuit.com/v1/openid_connect/userinfo' : 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo' this.apiBaseUrl = config.environment === 'production' ? 'https://quickbooks.api.intuit.com' : 'https://sandbox-quickbooks.api.intuit.com' this.scopes = { accounting: 'com.intuit.quickbooks.accounting', // Payment: 'com.intuit.quickbooks.payment', // Payroll: 'com.intuit.quickbooks.payroll', // TimeTracking: 'com.intuit.quickbooks.payroll.timetracking', // Benefits: 'com.intuit.quickbooks.payroll.benefits', profile: 'profile', email: 'email', phone: 'phone', address: 'address', openid: 'openid', // IntuitName: 'intuit_name', } this.minorversion = '65' this.accessTokenLatency = 60 // 60 second buffer for access token refresh this.refreshTokenLatency = 60 * 60 * 24 * 3 // 3 day buffer for refresh token refresh } getUnixTimestamp() { return Math.floor(Date.now() / 1000) } // Generate a random string that is 32 characters long (two characters per byte) createStateString() { return crypto.randomBytes(16).toString('hex') } // Methods getAuthUrl() { const query = querystring.encode({ client_id: this.clientId, response_type: this.responseType, state: this.createStateString(), scope: [ this.scopes.accounting, this.scopes.openid, this.scopes.profile, this.scopes.email, this.scopes.phone, this.scopes.address, ].join(' '), redirect_uri: this.redirectUri, }) return `${this.authorizeEndpoint}?${query}` } isAccessTokenValid(token: Token) { return token.expires_at > this.getUnixTimestamp() } isRefreshTokenValid(token: Token) { return token.x_refresh_token_expires_at > this.getUnixTimestamp() } parseToken(token: any, realm_id: string): Token { return { ...token, realm_id, expires_at: this.getUnixTimestamp() + token.expires_in - this.accessTokenLatency, x_refresh_token_expires_at: this.getUnixTimestamp() + token.x_refresh_token_expires_in - this.refreshTokenLatency, } } getAuthHeader() { return Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64') } async getTokenFromGrant(grant: Grant): Promise<Token> { grantSchema.parse(grant) const body = { grant_type: 'authorization_code', code: grant.code, redirect_uri: this.redirectUri, } const res = await fetch(this.tokenEndpoint, { method: 'POST', headers: { Authorization: `Basic ${this.getAuthHeader()}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: querystring.encode(body), }) const data = await res.json() // Parse the response as JSON and return it // Manually add the created_at property // Subtracting 10 seconds of latency to the created_at property // to ensure the token is detected as expired before it actually is const token = this.parseToken(data, grant.realmId) return token } async getRefreshedToken(token: Token): Promise<Token> { const body = { grant_type: 'refresh_token', refresh_token: token.refresh_token } const res = await fetch(this.tokenEndpoint, { method: 'POST', headers: { Authorization: `Basic ${this.getAuthHeader()}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: querystring.encode(body), }) const data = await res.json() const refreshedToken = this.parseToken(data, token.realm_id) return refreshedToken } async getValidToken(token: any): Promise<Token> { // Parse the token token = tokenSchema.parse(token) if (this.isAccessTokenValid(token)) { return token } if (!this.isRefreshTokenValid(token)) { throw new Error('Refresh token is expired') } return this.getRefreshedToken(token) } async getUserInfo(token: Token): Promise<any> { const res = await fetch(this.userEndpoint, { headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/json', }, }) const data = await res.json() return data } async getCompanyInfo(token: Token): Promise<any> { // Build the url const url = `${this.apiBaseUrl}/v3/company/${token.realm_id}/companyinfo/${token.realm_id}?minorversion=${this.minorversion}` const res = await fetch(url, { headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/json', }, }) const data = await res.json() // Check if data has a property called CompanyInfo const parsed = z.object({ CompanyInfo: z.any() }).parse(data) return parsed.CompanyInfo } async query({ token, query }: { token: any; query: string }): Promise<any> { token = await this.getValidToken(token) // Build the url const url = `${this.apiBaseUrl}/v3/company/${token.realm_id}/query?query=${query}&minorverion=${this.minorversion}` const res = await fetch(url, { headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/json', }, }) const data = await res.json() return data } // Do the same sort of work as above to see what else can be generalized async post({ token, endpoint, body }: { token: any; endpoint: string; body: any }): Promise<any> { token = await this.getValidToken(token) const url = `${this.apiBaseUrl}/v3/company/${token.realm_id}${endpoint}` const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(body), }) const data = await res.json() return data } async revokeAccess(token: Token) { const body = { token: token.access_token } const res = await fetch(this.revokeEndpoint, { method: 'POST', headers: { Authorization: `Basic ${this.getAuthHeader()}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(body), }) const data = await res.json() return data } }