From 6d91ed73a54567238dc994348f5b9ec6897e2ff9 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Apr 2026 13:10:27 +0545 Subject: [PATCH 1/3] fix(OUT-3575): handle Intuit OAuth's new error format for auth errors Intuit silently changed their OAuth error response from Axios-style `{ response: { status, data } }` to `{ error, error_description, intuit_tid }`. This caused refresh token expiry errors to go undetected, preventing notification emails to IUs. Add isIntuitOAuthError type guard and update all error handling paths (withErrorHandler, auth.service, error utils). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/core/exceptions/custom.ts | 19 +++++++++++++++++++ src/app/api/core/utils/withErrorHandler.ts | 11 +++++++++-- src/app/api/quickbooks/auth/auth.service.ts | 11 +++++++---- src/constant/intuitErrorCode.ts | 4 ++++ src/utils/error.ts | 13 ++++++++++++- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/app/api/core/exceptions/custom.ts b/src/app/api/core/exceptions/custom.ts index c9d6538a..fa731ef1 100644 --- a/src/app/api/core/exceptions/custom.ts +++ b/src/app/api/core/exceptions/custom.ts @@ -14,3 +14,22 @@ 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 } + */ +export function isIntuitOAuthError( + error: unknown, +): error is { error: string; error_description: string; intuit_tid: string } { + return ( + typeof error === 'object' && + error !== null && + 'error' in error && + typeof (error as any).error === 'string' && + 'error_description' in error && + typeof (error as any).error_description === 'string' && + 'intuit_tid' in error && + typeof (error as any).intuit_tid === 'string' + ) +} diff --git a/src/app/api/core/utils/withErrorHandler.ts b/src/app/api/core/utils/withErrorHandler.ts index 838b8bf5..b2b9b2c7 100644 --- a/src/app/api/core/utils/withErrorHandler.ts +++ b/src/app/api/core/utils/withErrorHandler.ts @@ -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' @@ -69,6 +72,9 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => { message = error.message || message } else if (error instanceof Error && error.message) { message = error.message + } else if (isIntuitOAuthError(error)) { + message = error.error + status = httpStatus.BAD_REQUEST } else if (isAxiosError(error)) { message = error.response.data.error status = error.response.status @@ -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) } diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index a76244d7..e043cf73 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -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' @@ -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 { @@ -328,14 +329,16 @@ export class AuthService extends BaseService { ) } } catch (error: unknown) { - if (isAxiosError(error)) { + console.error('AuthService#handleConnectionError | 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) diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index 81aa38ed..b058875a 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -3,3 +3,7 @@ export const AccountErrorCodes = [ 6190, // account suspended 6000, // business validation error ] + +export const OAuthErrorCodes = { + INVALID_GRANT: 'invalid_grant', +} diff --git a/src/utils/error.ts b/src/utils/error.ts index f4043689..da2f2f38 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -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' @@ -35,6 +40,12 @@ export const getMessageAndCodeFromError = ( return { message: errorMessage, code: error.status } } else if (error instanceof Error && error.message) { return { message: error.message, code } + } else if (isIntuitOAuthError(error)) { + const message = + error.error === OAuthErrorCodes.INVALID_GRANT + ? refreshTokenExpireMessage + : error.error + return { message, code: httpStatus.BAD_REQUEST } } else if (isAxiosError(error)) { return { message: error.response.data.error, code: error.response.status } } From ff99634d37f90a3348f2fbc9a31002bca9a06eb5 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Apr 2026 13:56:29 +0545 Subject: [PATCH 2/3] fix(OUT-3575): wrap Intuit OAuth errors in Error subclass to survive pRetry pRetry discards non-Error throws, so the raw Intuit OAuth plain object was being replaced with a generic TypeError before reaching our error handlers. Wrap in IntuitOAuthError extends Error with a fromRaw() factory method so the error fields survive the retry pipeline. Also fix branch ordering so isIntuitOAuthError is checked before instanceof Error. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/core/exceptions/custom.ts | 55 ++++++++++++++++----- src/app/api/core/utils/withErrorHandler.ts | 4 +- src/app/api/quickbooks/auth/auth.service.ts | 2 +- src/constant/intuitErrorCode.ts | 2 +- src/utils/error.ts | 4 +- src/utils/intuit.ts | 7 ++- 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/app/api/core/exceptions/custom.ts b/src/app/api/core/exceptions/custom.ts index fa731ef1..a80780d6 100644 --- a/src/app/api/core/exceptions/custom.ts +++ b/src/app/api/core/exceptions/custom.ts @@ -18,18 +18,47 @@ export function isAxiosError( /** * 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 function isIntuitOAuthError( - error: unknown, -): error is { error: string; error_description: string; intuit_tid: string } { - return ( - typeof error === 'object' && - error !== null && - 'error' in error && - typeof (error as any).error === 'string' && - 'error_description' in error && - typeof (error as any).error_description === 'string' && - 'intuit_tid' in error && - typeof (error as any).intuit_tid === 'string' - ) +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 + 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 } diff --git a/src/app/api/core/utils/withErrorHandler.ts b/src/app/api/core/utils/withErrorHandler.ts index b2b9b2c7..d61d41ba 100644 --- a/src/app/api/core/utils/withErrorHandler.ts +++ b/src/app/api/core/utils/withErrorHandler.ts @@ -70,11 +70,11 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => { } else if (error instanceof RetryableError) { status = error.status message = error.message || message - } else if (error instanceof Error && error.message) { - message = error.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)) { message = error.response.data.error status = error.response.status diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index e043cf73..48eecc0b 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -329,7 +329,7 @@ export class AuthService extends BaseService { ) } } catch (error: unknown) { - console.error('AuthService#handleConnectionError | Error =', error) + console.error('AuthService#getQBPortalConnection | Error =', error) if (isIntuitOAuthError(error)) { // Special handling for refresh token expired diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index b058875a..f142f737 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -6,4 +6,4 @@ export const AccountErrorCodes = [ export const OAuthErrorCodes = { INVALID_GRANT: 'invalid_grant', -} +} as const diff --git a/src/utils/error.ts b/src/utils/error.ts index da2f2f38..fbe3ba49 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -38,14 +38,14 @@ export const getMessageAndCodeFromError = ( errorMessage = (error.errors?.[0] as IntuitErrorType).Detail } return { message: errorMessage, code: error.status } - } else if (error instanceof Error && error.message) { - return { message: error.message, code } } 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)) { return { message: error.response.data.error, code: error.response.status } } diff --git a/src/utils/intuit.ts b/src/utils/intuit.ts index 2de2cb94..b9310a86 100644 --- a/src/utils/intuit.ts +++ b/src/utils/intuit.ts @@ -1,3 +1,4 @@ +import { IntuitOAuthError } from '@/app/api/core/exceptions/custom' import { withRetry } from '@/app/api/core/utils/withRetry' import { intuitClientId, @@ -53,7 +54,11 @@ export default class Intuit { } 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( From e5136c1104db51310acc0750d27756c258f9551e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Apr 2026 14:12:47 +0545 Subject: [PATCH 3/3] fix(OUT-3575): wrap Intuit OAuth errors in _authorizeUri and _createToken Apply the same IntuitOAuthError.fromRaw() wrapping to all Intuit OAuth SDK calls so pRetry doesn't discard plain-object errors from any path. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/intuit.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/utils/intuit.ts b/src/utils/intuit.ts index b9310a86..299510ed 100644 --- a/src/utils/intuit.ts +++ b/src/utils/intuit.ts @@ -41,16 +41,24 @@ 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) {