Skip to content

Commit 29450bd

Browse files
committed
feat(quickbooks): support credential-scoped OAuth apps
1 parent 8f3a30e commit 29450bd

133 files changed

Lines changed: 24692 additions & 488 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/content/docs/integrations/quickbooks.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1111
/>
1212

1313
{/* MANUAL-CONTENT-START:intro */}
14-
Connect one QuickBooks Online company per credential. During OAuth, choose the company that the workflow should access; Sim binds that company to the credential automatically, so you do not enter a realm ID or API host.
14+
Connect one QuickBooks Online company per credential. Create an app in the Intuit Developer Portal, register `https://<your-sim-host>/api/auth/oauth2/callback/quickbooks` as its redirect URI, then enter that app's client ID and client secret in Sim and select its Sandbox or Production environment. Sim encrypts this app configuration on the credential and uses it for authorization, refresh, and revocation.
15+
16+
During OAuth, choose the company that the workflow should access. Intuit returns the realm ID in the callback, and Sim verifies that the issued token can read CompanyInfo through that company-scoped API path before binding it to the credential, so you do not enter a realm ID or API host. The `CompanyInfo.Id` field is a separate entity ID and is not used as the realm ID.
1517

1618
Master Data, Sales, and Purchasing transaction reads support **List** and **By ID** modes. List actions return at most one page. Use `nextStartPosition` in another workflow step when `hasMore` is true. Sim does not paginate, retry, or fetch related records automatically.
1719

@@ -508,12 +510,12 @@ Create a Service or Non-inventory item in QuickBooks Online
508510
| --------- | ---- | -------- | ----------- |
509511
| `name` | string | Yes | Unique item name |
510512
| `itemType` | string | Yes | Writable item type: service or non_inventory |
511-
| `incomeAccountId` | string | No | Sales of Product Income account ID recording proceeds from the sale. Required for Service items, optional for Non-inventory items and for France locales |
513+
| `incomeAccountId` | string | No | Sales of Product Income account ID recording proceeds from the sale. Intuit requires it for Service items except in France locales |
512514
| `description` | string | No | Sales description |
513515
| `unitPrice` | number | No | Sales price per unit |
514516
| `purchaseDescription` | string | No | Purchase description |
515517
| `purchaseCost` | number | No | Purchase cost per unit |
516-
| `expenseAccountId` | string | Yes | Cost of Goods Sold account ID used to pay the vendor for this item. Required for both Service and Non-inventory items, except in France locales where it is optional |
518+
| `expenseAccountId` | string | No | Cost of Goods Sold account ID used to pay the vendor for this item. Intuit requires it for Service and Non-inventory items except in France locales |
517519
| `taxable` | boolean | No | Whether the item is taxable |
518520
| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters |
519521

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7498,6 +7498,90 @@
74987498
"additionalProperties": false
74997499
},
75007500
"description": "Authorization servers available for this OAuth service."
7501+
},
7502+
"fields": {
7503+
"maxItems": 20,
7504+
"type": "array",
7505+
"items": {
7506+
"type": "object",
7507+
"properties": {
7508+
"id": {
7509+
"type": "string",
7510+
"minLength": 1,
7511+
"maxLength": 255,
7512+
"description": "Exact create-body field name."
7513+
},
7514+
"label": {
7515+
"type": "string",
7516+
"minLength": 1,
7517+
"maxLength": 255,
7518+
"description": "Human-readable field label."
7519+
},
7520+
"placeholder": {
7521+
"type": "string",
7522+
"minLength": 1,
7523+
"maxLength": 1000,
7524+
"description": "Suggested input placeholder."
7525+
},
7526+
"required": {
7527+
"type": "boolean",
7528+
"description": "Whether the field is required for the selected flow."
7529+
},
7530+
"secret": {
7531+
"type": "boolean",
7532+
"description": "Whether the submitted field is write-only secret material."
7533+
},
7534+
"multiline": {
7535+
"type": "boolean",
7536+
"description": "Whether the field is intended for multi-line input."
7537+
},
7538+
"requiredForAuthMethods": {
7539+
"description": "Authentication methods for which this field is required.",
7540+
"minItems": 1,
7541+
"maxItems": 10,
7542+
"type": "array",
7543+
"items": {
7544+
"type": "string",
7545+
"minLength": 1,
7546+
"maxLength": 64
7547+
}
7548+
},
7549+
"options": {
7550+
"description": "Fixed values accepted by a selector field.",
7551+
"minItems": 1,
7552+
"maxItems": 20,
7553+
"type": "array",
7554+
"items": {
7555+
"type": "object",
7556+
"properties": {
7557+
"value": {
7558+
"type": "string",
7559+
"minLength": 1,
7560+
"maxLength": 255,
7561+
"description": "Submitted option value."
7562+
},
7563+
"label": {
7564+
"type": "string",
7565+
"minLength": 1,
7566+
"maxLength": 255,
7567+
"description": "Human-readable option label."
7568+
}
7569+
},
7570+
"required": ["value", "label"],
7571+
"additionalProperties": false
7572+
}
7573+
},
7574+
"hint": {
7575+
"description": "Provider-specific setup guidance.",
7576+
"type": "string",
7577+
"minLength": 1,
7578+
"maxLength": 2000
7579+
}
7580+
},
7581+
"required": ["id", "label", "placeholder", "required", "secret", "multiline"],
7582+
"additionalProperties": false
7583+
},
7584+
"description": "Write-only setup fields required before starting this OAuth flow."
75017585
}
75027586
},
75037587
"required": [
@@ -7508,7 +7592,8 @@
75087592
"providerFamily",
75097593
"available",
75107594
"supportsReconnect",
7511-
"authorizationOptions"
7595+
"authorizationOptions",
7596+
"fields"
75127597
],
75137598
"additionalProperties": false
75147599
},
@@ -7710,6 +7795,7 @@
77107795
"providerFamily": "salesforce",
77117796
"available": true,
77127797
"supportsReconnect": true,
7798+
"fields": [],
77137799
"authorizationOptions": [
77147800
{
77157801
"providerId": "salesforce",
@@ -7905,6 +7991,31 @@
79057991
"minLength": 1,
79067992
"maxLength": 255,
79077993
"description": "Name shown for the new credential in Sim."
7994+
},
7995+
"oauthClientConfig": {
7996+
"description": "Write-only OAuth app configuration when required by the provider.",
7997+
"type": "object",
7998+
"properties": {
7999+
"clientId": {
8000+
"type": "string",
8001+
"minLength": 1,
8002+
"maxLength": 255,
8003+
"description": "Client ID for the caller-managed Intuit OAuth application."
8004+
},
8005+
"clientSecret": {
8006+
"type": "string",
8007+
"minLength": 1,
8008+
"maxLength": 512,
8009+
"description": "Write-only client secret for the caller-managed Intuit OAuth application."
8010+
},
8011+
"environment": {
8012+
"type": "string",
8013+
"enum": ["sandbox", "production"],
8014+
"description": "Intuit company environment used for authorization and API requests."
8015+
}
8016+
},
8017+
"required": ["clientId", "clientSecret", "environment"],
8018+
"additionalProperties": false
79088019
}
79098020
},
79108021
"required": ["workspaceId", "providerId", "displayName"],
@@ -7924,6 +8035,31 @@
79248035
"minLength": 1,
79258036
"maxLength": 255,
79268037
"description": "Existing OAuth credential to reconnect in place."
8038+
},
8039+
"oauthClientConfig": {
8040+
"description": "Write-only OAuth app configuration when required by the provider.",
8041+
"type": "object",
8042+
"properties": {
8043+
"clientId": {
8044+
"type": "string",
8045+
"minLength": 1,
8046+
"maxLength": 255,
8047+
"description": "Client ID for the caller-managed Intuit OAuth application."
8048+
},
8049+
"clientSecret": {
8050+
"type": "string",
8051+
"minLength": 1,
8052+
"maxLength": 512,
8053+
"description": "Write-only client secret for the caller-managed Intuit OAuth application."
8054+
},
8055+
"environment": {
8056+
"type": "string",
8057+
"enum": ["sandbox", "production"],
8058+
"description": "Intuit company environment used for authorization and API requests."
8059+
}
8060+
},
8061+
"required": ["clientId", "clientSecret", "environment"],
8062+
"additionalProperties": false
79278063
}
79288064
},
79298065
"required": ["workspaceId", "credentialId"],

‎apps/sim/.env.example‎

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

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

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

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

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

6665
afterAll(resetEnvFlagsMock)
@@ -133,68 +132,6 @@ describe('auth catch-all route managed OAuth callbacks', () => {
133132
})
134133
})
135134

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-
198135
describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
199136
beforeEach(() => {
200137
vi.clearAllMocks()

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

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ 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'
1312
import { handleCredentialGroupOAuthCallback } from '@/app/api/credential-groups/oauth-callback'
1413

1514
export const dynamic = 'force-dynamic'
@@ -107,30 +106,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
107106
return NextResponse.json(createAnonymousSession())
108107
}
109108

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-
134109
return betterAuthGET(request)
135110
})
136111

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({
4848
vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata }))
4949

5050
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
51+
import { createQuickBooksAccountId } from '@/lib/oauth/quickbooks'
5152
import { GET, POST } from '@/app/api/auth/oauth/token/route'
5253

5354
describe('OAuth Token API Routes', () => {
@@ -104,7 +105,11 @@ describe('OAuth Token API Routes', () => {
104105
})
105106
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
106107
id: 'credential-id',
107-
accountId: 'quickbooks:123456789:intuit-subject-01234567-89ab-4def-8abc-0123456789ab',
108+
accountId: createQuickBooksAccountId(
109+
'123456789',
110+
'intuit-subject-01234567-89ab-4def-8abc-0123456789ab',
111+
'sandbox'
112+
),
108113
accessToken: 'test-token',
109114
refreshToken: 'refresh-token',
110115
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
@@ -126,6 +131,7 @@ describe('OAuth Token API Routes', () => {
126131
accessToken: 'fresh-token',
127132
credentialType: 'oauth',
128133
realmId: '123456789',
134+
quickBooksEnvironment: 'sandbox',
129135
})
130136
})
131137

0 commit comments

Comments
 (0)