Skip to content
Open
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
19 changes: 19 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2494,6 +2494,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 @@ -455,6 +456,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
177 changes: 177 additions & 0 deletions apps/docs/content/docs/en/integrations/quickbooks.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
title: QuickBooks
description: Read procurement data from QuickBooks Online
---

import { BlockInfoCard } from "@/components/ui/block-info-card"

<BlockInfoCard
type="quickbooks"
color="#2CA01C"
/>

## Usage Instructions

Connect one QuickBooks Online company and read its company profile, vendors, purchase orders, and bills. The connected company is selected during OAuth and cannot be overridden by workflow input.



## Actions

### `quickbooks_get_company_info`

Get information about the connected QuickBooks Online company

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `company` | json | Verified QuickBooks CompanyInfo object, including Id, CompanyName, LegalName, addresses, contact details, NameValue settings, and MetaData when populated |
| ↳ `Id` | string | QuickBooks CompanyInfo entity ID \(commonly "1"\); this is not the OAuth realmId |
| ↳ `SyncToken` | string | CompanyInfo sync token |
| ↳ `CompanyName` | string | Company display name |
| ↳ `LegalName` | string | Company legal name |
| ↳ `CompanyAddr` | json | Company address |
| ↳ `CustomerCommunicationAddr` | json | Customer communication address |
| ↳ `LegalAddr` | json | Company legal address |
| ↳ `PrimaryPhone` | json | Primary phone details |
| ↳ `Email` | json | Company email details |
| ↳ `WebAddr` | json | Company website details |
| ↳ `CompanyStartDate` | string | Company start date |
| ↳ `Country` | string | Company country code |
| ↳ `FiscalYearStartMonth` | string | Fiscal year starting month |
| ↳ `DefaultTimeZone` | string | Company default time zone |
| ↳ `NameValue` | array | QuickBooks company settings represented as name/value entries |
| ↳ `MetaData` | json | CompanyInfo creation and update timestamps |
| ↳ `CreateTime` | string | Entity creation timestamp |
| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp |
| `time` | string | QuickBooks response timestamp |

### `quickbooks_list_bills`

List bills in the connected QuickBooks Online company

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `startPosition` | number | Yes | One-based position of the first bill to return |
| `maxResults` | number | Yes | Number of bills to request \(1–100\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `startPosition` | number | One-based position of the first item in this response |
| `maxResults` | number | Actual number of items reported for this response |
| `nextStartPosition` | number | Position to use when explicitly requesting the next page |
| `hasMore` | boolean | Conservative indication that another page may exist |
| `time` | string | QuickBooks response timestamp |
| `items` | array | Bill objects returned by QuickBooks |
| ↳ `Id` | string | Bill ID |
| ↳ `SyncToken` | string | Bill sync token |
| ↳ `DocNumber` | string | Bill reference number |
| ↳ `TxnDate` | string | Bill transaction date |
| ↳ `DueDate` | string | Bill due date |
| ↳ `VendorRef` | json | Bill vendor reference |
| ↳ `value` | string | QuickBooks entity ID |
| ↳ `name` | string | QuickBooks entity display name |
| ↳ `APAccountRef` | json | Accounts payable account reference |
| ↳ `value` | string | QuickBooks entity ID |
| ↳ `name` | string | QuickBooks entity display name |
| ↳ `CurrencyRef` | json | Bill currency reference |
| ↳ `value` | string | QuickBooks entity ID |
| ↳ `name` | string | QuickBooks entity display name |
| ↳ `ExchangeRate` | number | Bill exchange rate |
| ↳ `Line` | array | Bill line items and their detail objects |
| ↳ `TotalAmt` | number | Bill total amount |
| ↳ `Balance` | number | Unpaid bill balance |
| ↳ `PrivateNote` | string | Private bill note |
| ↳ `MetaData` | json | Bill creation and update timestamps |
| ↳ `CreateTime` | string | Entity creation timestamp |
| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp |

### `quickbooks_list_purchase_orders`

List purchase orders in the connected QuickBooks Online company

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `startPosition` | number | Yes | One-based position of the first purchase order to return |
| `maxResults` | number | Yes | Number of purchase orders to request \(1–100\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `startPosition` | number | One-based position of the first item in this response |
| `maxResults` | number | Actual number of items reported for this response |
| `nextStartPosition` | number | Position to use when explicitly requesting the next page |
| `hasMore` | boolean | Conservative indication that another page may exist |
| `time` | string | QuickBooks response timestamp |
| `items` | array | PurchaseOrder objects returned by QuickBooks |
| ↳ `Id` | string | Purchase order ID |
| ↳ `SyncToken` | string | Purchase order sync token |
| ↳ `DocNumber` | string | Purchase order number |
| ↳ `TxnDate` | string | Purchase order date |
| ↳ `VendorRef` | json | Purchase order vendor reference |
| ↳ `value` | string | QuickBooks entity ID |
| ↳ `name` | string | QuickBooks entity display name |
| ↳ `CurrencyRef` | json | Purchase order currency reference |
| ↳ `value` | string | QuickBooks entity ID |
| ↳ `name` | string | QuickBooks entity display name |
| ↳ `ExchangeRate` | number | Purchase order exchange rate |
| ↳ `Line` | array | Purchase order line items and their detail objects |
| ↳ `TotalAmt` | number | Purchase order total amount |
| ↳ `PrivateNote` | string | Private purchase order note |
| ↳ `MetaData` | json | Purchase order creation and update timestamps |
| ↳ `CreateTime` | string | Entity creation timestamp |
| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp |

### `quickbooks_list_vendors`

List vendors in the connected QuickBooks Online company

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `startPosition` | number | Yes | One-based position of the first vendor to return |
| `maxResults` | number | Yes | Number of vendors to request \(1–100\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `startPosition` | number | One-based position of the first item in this response |
| `maxResults` | number | Actual number of items reported for this response |
| `nextStartPosition` | number | Position to use when explicitly requesting the next page |
| `hasMore` | boolean | Conservative indication that another page may exist |
| `time` | string | QuickBooks response timestamp |
| `items` | array | Vendor objects returned by QuickBooks |
| ↳ `Id` | string | Vendor ID |
| ↳ `SyncToken` | string | Vendor sync token |
| ↳ `DisplayName` | string | Vendor display name |
| ↳ `CompanyName` | string | Vendor company name |
| ↳ `GivenName` | string | Vendor given name |
| ↳ `FamilyName` | string | Vendor family name |
| ↳ `PrintOnCheckName` | string | Name printed on checks |
| ↳ `Active` | boolean | Whether the vendor is active |
| ↳ `Vendor1099` | boolean | Whether the vendor is tracked for 1099 reporting |
| ↳ `BillAddr` | json | Vendor billing address |
| ↳ `PrimaryPhone` | json | Vendor primary phone details |
| ↳ `PrimaryEmailAddr` | json | Vendor primary email details |
| ↳ `Balance` | number | Vendor balance |
| ↳ `CurrencyRef` | json | Vendor currency reference |
| ↳ `value` | string | QuickBooks entity ID |
| ↳ `name` | string | QuickBooks entity display name |
| ↳ `MetaData` | json | Vendor creation and update timestamps |
| ↳ `CreateTime` | string | Entity creation timestamp |
| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp |
5 changes: 5 additions & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# 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
Loading
Loading