Skip to content

Commit 89c33f2

Browse files
committed
feat(integrations): add validated QuickBooks integration
1 parent b01b41f commit 89c33f2

113 files changed

Lines changed: 20885 additions & 12 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2710,6 +2710,25 @@ export function BrexIcon(props: SVGProps<SVGSVGElement>) {
27102710
)
27112711
}
27122712

2713+
/**
2714+
* Official QuickBooks circular mark, cropped from the user-supplied
2715+
* Intuit_QuickBooks_logo.svg wordmark.
2716+
*/
2717+
export function QuickBooksIcon(props: SVGProps<SVGSVGElement>) {
2718+
return (
2719+
<svg {...props} viewBox='0 0 61.54 61.54' fill='none' xmlns='http://www.w3.org/2000/svg'>
2720+
<path
2721+
fill='#2CA01C'
2722+
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'
2723+
/>
2724+
<path
2725+
fill='#FFF'
2726+
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'
2727+
/>
2728+
</svg>
2729+
)
2730+
}
2731+
27132732
export function BrightDataIcon(props: SVGProps<SVGSVGElement>) {
27142733
return (
27152734
<svg

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ import {
191191
PulseIcon,
192192
QdrantIcon,
193193
QuartrIcon,
194+
QuickBooksIcon,
194195
QuiverIcon,
195196
RabbitmqIcon,
196197
RailwayIcon,
@@ -491,6 +492,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
491492
pulse_v2: PulseIcon,
492493
qdrant: QdrantIcon,
493494
quartr: QuartrIcon,
495+
quickbooks: QuickBooksIcon,
494496
quiver: QuiverIcon,
495497
rabbitmq: RabbitmqIcon,
496498
railway: RailwayIcon,

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@
203203
"pulse",
204204
"qdrant",
205205
"quartr",
206+
"quickbooks",
206207
"quiver",
207208
"rabbitmq",
208209
"railway",

apps/docs/content/docs/en/integrations/quickbooks.mdx

Lines changed: 2919 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
152152
# TIKTOK_CLIENT_ID=
153153
# TIKTOK_CLIENT_SECRET=
154154

155+
# QuickBooks Online OAuth (Optional - credentials from the Intuit Developer Portal)
156+
# QUICKBOOKS_CLIENT_ID=
157+
# QUICKBOOKS_CLIENT_SECRET=
158+
# QUICKBOOKS_ENV=sandbox # Required when QuickBooks is configured: sandbox or production
159+
155160
# Azure Blob Storage
156161
# AZURE_ACCOUNT_NAME= # Azure storage account name
157162
# AZURE_ACCOUNT_KEY= # Azure storage account key

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ vi.mock('@/app/api/credential-groups/oauth-callback', () => ({
6060
handleCredentialGroupOAuthCallback: handlerMocks.credentialGroupCallback,
6161
}))
6262

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

6566
afterAll(resetEnvFlagsMock)
@@ -132,6 +133,68 @@ describe('auth catch-all route managed OAuth callbacks', () => {
132133
})
133134
})
134135

136+
describe('auth catch-all route QuickBooks callback', () => {
137+
beforeEach(() => {
138+
vi.clearAllMocks()
139+
setEnvFlags({ isAuthDisabled: false })
140+
})
141+
142+
it('binds the callback realm only while Better Auth processes the OAuth response', async () => {
143+
const { NextResponse } = await import('next/server')
144+
handlerMocks.betterAuthGET.mockImplementationOnce(async () => {
145+
await Promise.resolve()
146+
expect(getQuickBooksCallbackRealm()).toBe('123456789')
147+
return new NextResponse(null, { status: 302 })
148+
})
149+
const request = createMockRequest(
150+
'GET',
151+
undefined,
152+
{},
153+
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test&realmId=123456789'
154+
)
155+
156+
const response = await GET(request)
157+
158+
expect(response.status).toBe(302)
159+
expect(handlerMocks.betterAuthGET).toHaveBeenCalledOnce()
160+
expect(() => getQuickBooksCallbackRealm()).toThrow(/did not include a company identity/)
161+
})
162+
163+
it('delegates a denied callback without requiring a realm', async () => {
164+
const { NextResponse } = await import('next/server')
165+
handlerMocks.betterAuthGET.mockImplementationOnce(async () => {
166+
expect(() => getQuickBooksCallbackRealm()).toThrow(/did not include a company identity/)
167+
return new NextResponse(null, { status: 302 })
168+
})
169+
const request = createMockRequest(
170+
'GET',
171+
undefined,
172+
{},
173+
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?error=access_denied&state=test'
174+
)
175+
176+
const response = await GET(request)
177+
178+
expect(response.status).toBe(302)
179+
expect(handlerMocks.betterAuthGET).toHaveBeenCalledOnce()
180+
})
181+
182+
it.each([
183+
['missing', 'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test'],
184+
[
185+
'invalid',
186+
'http://localhost:3000/api/auth/oauth2/callback/quickbooks?code=test&state=test&realmId=not-a-company',
187+
],
188+
])('rejects a %s callback realm before Better Auth exchanges the code', async (_, url) => {
189+
const request = createMockRequest('GET', undefined, {}, url)
190+
191+
const response = await GET(request)
192+
193+
expect(response.status).toBe(400)
194+
expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled()
195+
})
196+
})
197+
135198
describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
136199
beforeEach(() => {
137200
vi.clearAllMocks()

apps/sim/app/api/auth/[...all]/route.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { isCredentialGroupOAuthState } from '@/lib/credential-groups/oauth-state'
1010
import { getCredentialGroupStandardOAuthProviderFromProviderId } from '@/lib/credential-groups/providers'
1111
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
12+
import { normalizeQuickBooksRealmId, withQuickBooksCallbackRealm } from '@/lib/oauth/quickbooks'
1213
import { handleCredentialGroupOAuthCallback } from '@/app/api/credential-groups/oauth-callback'
1314

1415
export const dynamic = 'force-dynamic'
@@ -106,6 +107,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
106107
return NextResponse.json(createAnonymousSession())
107108
}
108109

110+
if (path === 'oauth2/callback/quickbooks') {
111+
const authorizationCode = request.nextUrl.searchParams.get('code')
112+
if (!authorizationCode) return betterAuthGET(request)
113+
114+
const realmId = request.nextUrl.searchParams.get('realmId')
115+
if (!realmId) {
116+
return NextResponse.json(
117+
{ error: 'QuickBooks callback did not include a company identity.' },
118+
{ status: 400 }
119+
)
120+
}
121+
122+
try {
123+
normalizeQuickBooksRealmId(realmId)
124+
} catch {
125+
return NextResponse.json(
126+
{ error: 'QuickBooks callback included an invalid company identity.' },
127+
{ status: 400 }
128+
)
129+
}
130+
131+
return withQuickBooksCallbackRealm(realmId, () => betterAuthGET(request))
132+
}
133+
109134
return betterAuthGET(request)
110135
})
111136

apps/sim/app/api/auth/oauth/disconnect/route.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('OAuth Disconnect API Route', () => {
2020
beforeEach(() => {
2121
vi.clearAllMocks()
2222
resetDbChainMock()
23-
dbChainMockFns.where.mockResolvedValue([])
23+
dbChainMockFns.limit.mockResolvedValue([])
2424
})
2525

2626
it('should disconnect provider successfully', async () => {
@@ -93,7 +93,7 @@ describe('OAuth Disconnect API Route', () => {
9393
session: { id: 'session-1' },
9494
})
9595

96-
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
96+
dbChainMockFns.limit.mockRejectedValueOnce(new Error('Database error'))
9797

9898
const req = createMockRequest('POST', {
9999
provider: 'google',

apps/sim/app/api/auth/oauth/token/route.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,74 @@ describe('OAuth Token API Routes', () => {
8888

8989
expect(response.status).toBe(200)
9090
expect(data).toHaveProperty('accessToken', 'fresh-token')
91+
expect(data).not.toHaveProperty('realmId')
9192

9293
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
9394
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
9495
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).toHaveBeenCalled()
9596
})
9697

98+
it('returns realmId only for QuickBooks credentials', async () => {
99+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
100+
ok: true,
101+
authType: 'session',
102+
requesterUserId: 'test-user-id',
103+
credentialOwnerUserId: 'owner-user-id',
104+
})
105+
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
106+
id: 'credential-id',
107+
accountId: 'quickbooks:123456789:intuit-subject-01234567-89ab-4def-8abc-0123456789ab',
108+
accessToken: 'test-token',
109+
refreshToken: 'refresh-token',
110+
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
111+
providerId: 'quickbooks',
112+
})
113+
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
114+
accessToken: 'fresh-token',
115+
refreshed: false,
116+
})
117+
118+
const response = await POST(
119+
createMockRequest('POST', {
120+
credentialId: 'credential-id',
121+
})
122+
)
123+
124+
expect(response.status).toBe(200)
125+
expect(await response.json()).toEqual({
126+
accessToken: 'fresh-token',
127+
realmId: '123456789',
128+
})
129+
})
130+
131+
it('rejects a malformed QuickBooks company identity with reconnect guidance', async () => {
132+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
133+
ok: true,
134+
authType: 'session',
135+
requesterUserId: 'test-user-id',
136+
credentialOwnerUserId: 'owner-user-id',
137+
})
138+
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
139+
id: 'credential-id',
140+
accountId: 'malformed',
141+
accessToken: 'test-token',
142+
refreshToken: 'refresh-token',
143+
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
144+
providerId: 'quickbooks',
145+
})
146+
147+
const response = await POST(
148+
createMockRequest('POST', {
149+
credentialId: 'credential-id',
150+
})
151+
)
152+
const data = await response.json()
153+
154+
expect(response.status).toBe(401)
155+
expect(data.error).toMatch(/Reconnect the QuickBooks credential/)
156+
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
157+
})
158+
97159
it('should handle workflowId for server-side authentication', async () => {
98160
mockAuthorizeCredentialUse.mockResolvedValueOnce({
99161
ok: true,
@@ -734,6 +796,33 @@ describe('OAuth Token API Routes', () => {
734796
expect(data).toHaveProperty('error')
735797
})
736798

799+
it('rejects a malformed QuickBooks identity before reporting a missing token', async () => {
800+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
801+
ok: true,
802+
authType: 'session',
803+
requesterUserId: 'test-user-id',
804+
credentialOwnerUserId: 'test-user-id',
805+
})
806+
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
807+
id: 'credential-id',
808+
accountId: 'malformed',
809+
accessToken: null,
810+
refreshToken: 'refresh-token',
811+
providerId: 'quickbooks',
812+
})
813+
814+
const response = await GET(
815+
new NextRequest(
816+
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
817+
) as any
818+
)
819+
const data = await response.json()
820+
821+
expect(response.status).toBe(401)
822+
expect(data.error).toMatch(/Reconnect the QuickBooks credential/)
823+
expect(authOAuthUtilsMockFns.mockRefreshTokenIfNeeded).not.toHaveBeenCalled()
824+
})
825+
737826
it('should handle token refresh failure', async () => {
738827
mockAuthorizeCredentialUse.mockResolvedValueOnce({
739828
ok: true,

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import {
2424
import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token'
2525
import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
2626
import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
27-
import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution'
27+
import {
28+
completeOAuthCredentialToken,
29+
resolveCredentialToken,
30+
validateOAuthCredentialContext,
31+
} from '@/lib/oauth/token-resolution'
2832
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
2933
import { captureServerEvent } from '@/lib/posthog/server'
3034
import { getToolMetadata } from '@/tools/metadata'
@@ -326,6 +330,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
326330
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
327331
}
328332

333+
const contextValidation = validateOAuthCredentialContext(credential)
334+
if (!contextValidation.ok) {
335+
return NextResponse.json({ error: contextValidation.error }, { status: 401 })
336+
}
337+
329338
if (!credential.accessToken) {
330339
logger.warn(`[${requestId}] No access token available for credential`)
331340
return NextResponse.json({ error: 'No access token available' }, { status: 400 })

0 commit comments

Comments
 (0)