Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/app/api/core/exceptions/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,51 @@ export function isAxiosError(
'status' in (error as any).response
)
}

/**
* Intuit-Oauth has its own error format response.
* Format: { error: string, error_description: string, intuit_tid: string }
*
* Wrapping in an Error subclass so pRetry doesn't discard the original fields.
*/
export class IntuitOAuthError extends Error {
error: string
error_description: string
intuit_tid: string

constructor(raw: {
error: string
error_description: string
intuit_tid: string
}) {
super(raw.error_description)
this.name = 'IntuitOAuthError'
this.error = raw.error
this.error_description = raw.error_description
this.intuit_tid = raw.intuit_tid
}

static fromRaw(error: unknown): IntuitOAuthError | null {
const err = error as Record<string, unknown>
if (
typeof error === 'object' &&
error !== null &&
typeof err.intuit_tid === 'string' &&
typeof err.error === 'string' &&
typeof err.error_description === 'string'
) {
return new IntuitOAuthError(
err as {
error: string
error_description: string
intuit_tid: string
},
)
}
return null
}
}

export function isIntuitOAuthError(error: unknown): error is IntuitOAuthError {
return error instanceof IntuitOAuthError
}
11 changes: 9 additions & 2 deletions src/app/api/core/utils/withErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import APIError from '@/app/api/core/exceptions/api'
import httpStatus from 'http-status'
import { NextRequest, NextResponse } from 'next/server'
import { ZodError, ZodFormattedError } from 'zod'
import { isAxiosError } from '@/app/api/core/exceptions/custom'
import {
isAxiosError,
isIntuitOAuthError,
} from '@/app/api/core/exceptions/custom'
import * as Sentry from '@sentry/nextjs'
import { RetryableError } from '@/utils/error'

Expand Down Expand Up @@ -67,6 +70,9 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => {
} else if (error instanceof RetryableError) {
status = error.status
message = error.message || message
} else if (isIntuitOAuthError(error)) {
message = error.error
status = httpStatus.BAD_REQUEST
} else if (error instanceof Error && error.message) {
message = error.message
} else if (isAxiosError(error)) {
Expand All @@ -78,7 +84,8 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => {
if (
error instanceof APIError ||
error instanceof CopilotApiError ||
isAxiosError(error)
isAxiosError(error) ||
isIntuitOAuthError(error)
) {
Sentry.captureException(error)
}
Expand Down
11 changes: 7 additions & 4 deletions src/app/api/quickbooks/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import APIError from '@/app/api/core/exceptions/api'
import { isAxiosError } from '@/app/api/core/exceptions/custom'
import { isIntuitOAuthError } from '@/app/api/core/exceptions/custom'
import { BaseService } from '@/app/api/core/services/base.service'
import { AuthStatus } from '@/app/api/core/types/auth'
import { NotificationActions } from '@/app/api/core/types/notification'
Expand All @@ -9,6 +9,7 @@ import { SettingService } from '@/app/api/quickbooks/setting/setting.service'
import { SyncService } from '@/app/api/quickbooks/sync/sync.service'
import { TokenService } from '@/app/api/quickbooks/token/token.service'
import { intuitRedirectUri } from '@/config'
import { OAuthErrorCodes } from '@/constant/intuitErrorCode'
import { AccountTypeObj } from '@/constant/qbConnection'
import { ConnectionStatus } from '@/db/schema/qbConnectionLogs'
import {
Expand Down Expand Up @@ -328,14 +329,16 @@ export class AuthService extends BaseService {
)
}
} catch (error: unknown) {
if (isAxiosError(error)) {
console.error('AuthService#getQBPortalConnection | Error =', error)

if (isIntuitOAuthError(error)) {
// Special handling for refresh token expired
console.error(
`Refresh token is invalid or expired, reauthorization needed for portalId: ${portalId}.`,
{ message: error.response.data?.error },
{ message: error.error_description },
)

if (error.response.data?.error === 'invalid_grant') {
if (error.error === OAuthErrorCodes.INVALID_GRANT) {
// indicates that the refresh token is invalid
// turn off the sync and send notifications to IU (product and email)
await tokenService.turnOffSync(intuitRealmId)
Expand Down
4 changes: 4 additions & 0 deletions src/constant/intuitErrorCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ export const AccountErrorCodes = [
6190, // account suspended
6000, // business validation error
]

export const OAuthErrorCodes = {
INVALID_GRANT: 'invalid_grant',
} as const
13 changes: 12 additions & 1 deletion src/utils/error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import APIError from '@/app/api/core/exceptions/api'
import { isAxiosError } from '@/app/api/core/exceptions/custom'
import {
isAxiosError,
isIntuitOAuthError,
} from '@/app/api/core/exceptions/custom'
import { OAuthErrorCodes } from '@/constant/intuitErrorCode'
import { CopilotApiError, MessagableError } from '@/type/CopilotApiError'
import { refreshTokenExpireMessage } from '@/utils/auth'
import { IntuitAPIErrorMessage } from '@/utils/intuitAPI'
import httpStatus from 'http-status'

Expand Down Expand Up @@ -33,6 +38,12 @@ export const getMessageAndCodeFromError = (
errorMessage = (error.errors?.[0] as IntuitErrorType).Detail
}
return { message: errorMessage, code: error.status }
} else if (isIntuitOAuthError(error)) {
const message =
error.error === OAuthErrorCodes.INVALID_GRANT
? refreshTokenExpireMessage
: error.error
return { message, code: httpStatus.BAD_REQUEST }
} else if (error instanceof Error && error.message) {
return { message: error.message, code }
} else if (isAxiosError(error)) {
Expand Down
29 changes: 21 additions & 8 deletions src/utils/intuit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IntuitOAuthError } from '@/app/api/core/exceptions/custom'
import { withRetry } from '@/app/api/core/utils/withRetry'
import {
intuitClientId,
Expand Down Expand Up @@ -40,20 +41,32 @@ export default class Intuit {
}

async _authorizeUri(state: { token: string; originUrl?: string }) {
// AuthorizationUri
const authUri = await this.intuitQB.authorizeUri({
scope: [OAuthClient.scopes.Accounting, OAuthClient.scopes.OpenId],
state: JSON.stringify(state),
})
return authUri
try {
// AuthorizationUri
const authUri = await this.intuitQB.authorizeUri({
scope: [OAuthClient.scopes.Accounting, OAuthClient.scopes.OpenId],
state: JSON.stringify(state),
})
return authUri
} catch (error) {
throw IntuitOAuthError.fromRaw(error) ?? error
}
}

async _createToken(url: string) {
return await this.intuitQB.createToken(url)
try {
return await this.intuitQB.createToken(url)
} catch (error) {
throw IntuitOAuthError.fromRaw(error) ?? error
}
}

async _refreshAccessToken(refreshToken: string) {
return await this.intuitQB.refreshUsingToken(refreshToken)
try {
return await this.intuitQB.refreshUsingToken(refreshToken)
} catch (error: unknown) {
throw IntuitOAuthError.fromRaw(error) ?? error
}
}

async getRefreshedQBToken(
Expand Down
Loading