@romanzubenko_afternoon/quickbooks
Version:
QuickBooks JavaScript Client
254 lines (213 loc) • 7.62 kB
text/typescript
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
}
}