Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c332dda
feat(quickbooks): add read-only procurement integration
Jul 30, 2026
33d9afb
fix(quickbooks): address BugBot findings
Jul 30, 2026
29bfcc5
fix(quickbooks): require explicit API environment
Jul 31, 2026
cbc74bf
fix(quickbooks): validate refreshed execution context
Jul 31, 2026
311d32e
fix(oauth): reject invalid refresh lifetimes
Jul 31, 2026
19e00eb
fix(quickbooks): bound initial token exchange
Jul 31, 2026
00fc671
merge staging into feat/quickbooks-integration
Jul 31, 2026
25e5420
fix(quickbooks): harden OAuth company binding
Jul 31, 2026
970d3b1
fix(quickbooks): handle non-json token errors
Jul 31, 2026
6df18b0
refactor(quickbooks): align integration architecture
Aug 1, 2026
23b30ac
fix(serializer): preserve singleton canonical values
Aug 1, 2026
7f2dd27
revert(credentials): defer ambiguous draft handling
Aug 1, 2026
25a7532
chore(tools): sync QuickBooks metadata
Aug 1, 2026
9a6a51c
Merge remote-tracking branch 'origin/staging' into feat/quickbooks-in…
Aug 1, 2026
2c23c3b
Merge remote-tracking branch 'origin/staging' into feat/quickbooks-in…
Aug 2, 2026
8badd54
Merge remote-tracking branch 'origin/staging' into feat/quickbooks-in…
Aug 2, 2026
bb2bb1c
fix(quickbooks): extend refresh lock budget
Aug 3, 2026
e4ae172
Merge origin/staging into feat/quickbooks-integration
Aug 3, 2026
161c8a7
feat(quickbooks): add master data and simple CRUD (#6119)
BillLeoutsakosvl346 Aug 3, 2026
831490d
fix(quickbooks): clean up consolidated CI
Aug 4, 2026
0c3de74
Merge staging into feat/quickbooks-integration
Aug 4, 2026
de78c49
fix(quickbooks): correct API conformance, prevent write data loss, sh…
waleedlatif1 Aug 4, 2026
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
19 changes: 19 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2581,6 +2581,25 @@ export function BrexIcon(props: SVGProps<SVGSVGElement>) {
)
}

/**
* Official QuickBooks circular mark, cropped from the user-supplied
* Intuit_QuickBooks_logo.svg wordmark.
*/
export function QuickBooksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 61.54 61.54' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill='#2CA01C'
d='M30.77 61.54c16.99 0 30.77-13.78 30.77-30.77S47.76 0 30.77 0 0 13.78 0 30.77s13.77 30.77 30.77 30.77Z'
/>
<path
fill='#FFF'
d='M20.51 18.8c-6.61 0-11.97 5.36-11.97 11.97s5.35 11.96 11.97 11.96h1.71v-4.44h-1.71c-4.15 0-7.52-3.37-7.52-7.52 0-4.15 3.37-7.52 7.52-7.52h4.11V46.5c0 2.45 1.99 4.44 4.44 4.44V18.8h-8.55Zm20.52 23.93c6.61 0 11.97-5.36 11.97-11.96S47.65 18.81 41.03 18.81h-1.71v4.44h1.71c4.15 0 7.52 3.37 7.52 7.52s-3.37 7.52-7.52 7.52h-4.11V15.04c0-2.45-1.99-4.44-4.44-4.44v32.13h8.55Z'
/>
</svg>
)
}

export function BrightDataIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ import {
PulseIcon,
QdrantIcon,
QuartrIcon,
QuickBooksIcon,
QuiverIcon,
RailwayIcon,
RB2BIcon,
Expand Down Expand Up @@ -456,6 +457,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
pulse_v2: PulseIcon,
qdrant: QdrantIcon,
quartr: QuartrIcon,
quickbooks: QuickBooksIcon,
quiver: QuiverIcon,
railway: RailwayIcon,
rb2b: RB2BIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
"pulse",
"qdrant",
"quartr",
"quickbooks",
"quiver",
"railway",
"rb2b",
Expand Down
2,921 changes: 2,921 additions & 0 deletions apps/docs/content/docs/en/integrations/quickbooks.mdx

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# TIKTOK_CLIENT_ID=
# TIKTOK_CLIENT_SECRET=

# QuickBooks Online OAuth (Optional - credentials from the Intuit Developer Portal)
# QUICKBOOKS_CLIENT_ID=
# QUICKBOOKS_CLIENT_SECRET=
# QUICKBOOKS_ENV=sandbox # Required when QuickBooks is configured: sandbox or production

# Azure Blob Storage takes precedence over S3 if both are configured
# AZURE_ACCOUNT_NAME= # Azure storage account name
# AZURE_ACCOUNT_KEY= # Azure storage account key
Expand Down
65 changes: 65 additions & 0 deletions apps/sim/app/api/auth/[...all]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ vi.mock('@/lib/auth/anonymous', () => ({
createAnonymousSession: handlerMocks.createAnonymousSession,
}))

import { getQuickBooksCallbackRealm } from '@/lib/oauth/quickbooks'
import { GET, POST } from '@/app/api/auth/[...all]/route'

afterAll(resetEnvFlagsMock)
Expand Down Expand Up @@ -132,3 +133,67 @@ describe('auth catch-all route organization mutations', () => {
expect(json).toEqual({ data: { ok: true } })
})
})

describe('auth catch-all route QuickBooks callback', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnvFlags({ isAuthDisabled: false })
})

it('binds the callback realm only while Better Auth processes the OAuth response', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthGET.mockImplementationOnce(async () => {
await Promise.resolve()
expect(getQuickBooksCallbackRealm()).toBe('123456789')
return new NextResponse(null, { status: 302 })
})

const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test&realmId=123456789'
)

const res = await GET(req as any)

expect(res.status).toBe(302)
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
expect(() => getQuickBooksCallbackRealm()).toThrow(/did not include a company identity/)
})

it('delegates a denied callback without requiring a realm', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthGET.mockImplementationOnce(async () => {
expect(() => getQuickBooksCallbackRealm()).toThrow(/did not include a company identity/)
return new NextResponse(null, { status: 302 })
})

const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?error=access_denied&state=test'
)

const res = await GET(req as any)

expect(res.status).toBe(302)
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
})

it.each([
['missing', 'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test'],
[
'invalid',
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test&realmId=not-a-company',
],
])('rejects a %s callback realm before Better Auth exchanges the code', async (_, url) => {
const req = createMockRequest('GET', undefined, {}, url)

const res = await GET(req as any)

expect(res.status).toBe(400)
expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled()
})
})
27 changes: 27 additions & 0 deletions apps/sim/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { auth } from '@/lib/auth'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { normalizeQuickBooksRealmId, withQuickBooksCallbackRealm } from '@/lib/oauth/quickbooks'

export const dynamic = 'force-dynamic'

Expand All @@ -27,6 +28,32 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(createAnonymousSession())
}

if (path === 'oauth2/callback/quickbooks') {
const authorizationCode = request.nextUrl.searchParams.get('code')
if (!authorizationCode) {
return betterAuthGET(request)
}

const realmId = request.nextUrl.searchParams.get('realmId')
if (!realmId) {
return NextResponse.json(
{ error: 'QuickBooks callback did not include a company identity.' },
{ status: 400 }
)
}

try {
normalizeQuickBooksRealmId(realmId)
} catch {
return NextResponse.json(
{ error: 'QuickBooks callback included an invalid company identity.' },
{ status: 400 }
)
}

return withQuickBooksCallbackRealm(realmId, () => betterAuthGET(request))
}

return betterAuthGET(request)
})

Expand Down
89 changes: 89 additions & 0 deletions apps/sim/app/api/auth/oauth/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,74 @@ describe('OAuth Token API Routes', () => {

expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(data).not.toHaveProperty('realmId')

expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).toHaveBeenCalled()
})

it('returns realmId only for QuickBooks credentials', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accountId: 'quickbooks:123456789:intuit-subject-01234567-89ab-4def-8abc-0123456789ab',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'quickbooks',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})

const response = await POST(
createMockRequest('POST', {
credentialId: 'credential-id',
})
)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({
accessToken: 'fresh-token',
realmId: '123456789',
})
})

it('rejects a malformed QuickBooks company identity with reconnect guidance', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accountId: 'malformed',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'quickbooks',
})

const response = await POST(
createMockRequest('POST', {
credentialId: 'credential-id',
})
)
const data = await response.json()

expect(response.status).toBe(401)
expect(data.error).toMatch(/Reconnect the QuickBooks credential/)
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
})

it('should handle workflowId for server-side authentication', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -517,6 +579,33 @@ describe('OAuth Token API Routes', () => {
expect(data).toHaveProperty('error')
})

it('rejects a malformed QuickBooks identity before reporting a missing token', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accountId: 'malformed',
accessToken: null,
refreshToken: 'refresh-token',
providerId: 'quickbooks',
})

const response = await GET(
new NextRequest(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
) as any
)
const data = await response.json()

expect(response.status).toBe(401)
expect(data.error).toMatch(/Reconnect the QuickBooks credential/)
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
})

it('should handle token refresh failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
Expand Down
43 changes: 40 additions & 3 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { parseQuickBooksAccountId } from '@/lib/oauth/quickbooks'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getCredential,
Expand Down Expand Up @@ -254,6 +255,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const oauthActorId = authz.requesterUserId
const oauthWorkspaceId = authz.workspaceId ?? null

let realmId: string | undefined
if (credential.providerId === 'quickbooks') {
try {
realmId = parseQuickBooksAccountId(credential.accountId).realmId
} catch (error) {
return NextResponse.json(
{
error: getErrorMessage(
error,
'QuickBooks company identity is invalid. Reconnect the QuickBooks credential.'
),
},
{ status: 401 }
)
}
}

try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
Expand Down Expand Up @@ -311,6 +329,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
...(realmId && { realmId }),
...(apiDomain && { apiDomain }),
},
{ status: 200 }
Expand Down Expand Up @@ -369,14 +388,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}

const actorId = authz.requesterUserId
const workspaceId = authz.workspaceId ?? null

let realmId: string | undefined
if (credential.providerId === 'quickbooks') {
try {
realmId = parseQuickBooksAccountId(credential.accountId).realmId
} catch (error) {
return NextResponse.json(
{
error: getErrorMessage(
error,
'QuickBooks company identity is invalid. Reconnect the QuickBooks credential.'
),
},
{ status: 401 }
)
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (!credential.accessToken) {
logger.warn(`[${requestId}] No access token available for credential`)
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
}

const actorId = authz.requesterUserId
const workspaceId = authz.workspaceId ?? null

try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
Expand Down Expand Up @@ -435,6 +471,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
...(realmId && { realmId }),
...(apiDomain && { apiDomain }),
},
{ status: 200 }
Expand Down
Loading
Loading